Build Pagination and Search Without Stale-Result Bugs
Combine debounced search with monotonic page generation IDs and deduplication to eliminate race conditions and duplicated items during infinite scrolling.
1. The Core Challenge & Problem
2. Architectural Principles & Resolution
3. Complete Tested Flutter Code
main.dart (Flutter 3.x+ ready)import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ResilientPaginationDemo()));
class ResilientPaginationDemo extends StatefulWidget {
const ResilientPaginationDemo({super.key});
@override
State<ResilientPaginationDemo> createState() => _ResilientPaginationDemoState();
}
class _ResilientPaginationDemoState extends State<ResilientPaginationDemo> {
final ScrollController _scrollController = ScrollController();
final List<String> _items = [];
final Set<String> _seenIds = {};
int _generationId = 0;
int _currentPage = 1;
bool _isLoading = false;
bool _hasMore = true;
String _activeQuery = '';
@override
void initState() {
super.initState();
_fetchPage(query: _activeQuery, page: 1, isInitial: true);
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
if (!_isLoading && _hasMore) {
_fetchPage(query: _activeQuery, page: _currentPage + 1, isInitial: false);
}
}
}
void _onSearch(String newQuery) {
_activeQuery = newQuery;
_generationId++; // Advance generation token to invalidate any previous in-flight pages!
_currentPage = 1;
_hasMore = true;
_items.clear();
_seenIds.clear();
_fetchPage(query: _activeQuery, page: 1, isInitial: true);
}
Future<void> _fetchPage({required String query, required int page, required bool isInitial}) async {
final int thisGen = _generationId;
setState(() => _isLoading = true);
// Mock network call with simulated 400ms latency
await Future.delayed(const Duration(milliseconds: 400));
final newItems = List.generate(10, (i) => 'Item ${(page - 1) * 10 + i + 1} [$query]');
// Validate generation identity! Drop result if query changed while in flight!
if (!mounted || thisGen != _generationId) return;
setState(() {
_isLoading = false;
_currentPage = page;
for (final item in newItems) {
if (_seenIds.add(item)) {
_items.add(item);
}
}
if (page >= 4) _hasMore = false; // Limit to 40 mock items
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('T06.02: Resilient Pagination')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: TextField(
onChanged: _onSearch,
decoration: const InputDecoration(
labelText: 'Filter query (updates generation token)',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
),
Expanded(
child: ListView.builder(
controller: _scrollController,
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == _items.length) {
return const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()));
}
return ListTile(title: Text(_items[i]));
},
),
),
],
),
);
}
} 4. Expected Visual & Behavioral Result
5. Common Pitfalls & Traps
Pitfall #1: Failing to advance a generation ID when filters or sorting change
Remedy: Always increment a generation sequence number whenever query parameters reset, and reject responses matching older tokens.
Pitfall #2: Not deduplicating items before appending to the list
Remedy: Keep a Set<String> of item IDs to guarantee idempotency and avoid duplicate key errors in ListView children.
Pitfall #3: Triggering fetch calls on every ScrollController notification
Remedy: Wrap fetches with an isLoading lock and a scroll threshold (e.g. pixels >= maxScrollExtent - 200) to avoid firing 5 requests per second.