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
Combining search filters with infinite scroll pagination invariably introduces race conditions. For instance: 1. The user scrolls to the bottom of Page 1, triggering a fetch for Page 2. 2. While Page 2 is still in flight, the user types a new search query "apple". 3. The app resets the list for "apple" (Page 1). 4. Suddenly, the delayed Page 2 from the previous search query resolves and appends old items to the new "apple" list! 5. Rapid scroll bounces also trigger duplicate requests for the same page, causing repeated items in the UI.
2. Architectural Principles & Resolution
A robust pagination architecture requires three core components: 1. Search Generation Token: Increment an integer _generationId whenever the search query or sorting filter changes. Store this token in the page request. When a page response arrives: if (response.generationId != _currentGenerationId) return; // Drop stale page 2. Boolean Guard & Deduplication: Guard against duplicate triggers with an _isFetchingNextPage boolean, and store items using their unique IDs in a Set<String> _seenIds before appending to the displayed list. 3. ScrollNotification Threshold: Listen to ScrollEndNotification or check pixels >= maxScrollExtent - 200 rather than listening to every continuous pixel offset tick.
3. Complete 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
Infinite scrolling loads Page 2, 3, and 4 sequentially without duplicate items. When a new search query is typed, the generation token immediately prevents any in-flight requests from the previous query from contaminating the new list.
5. Common Pitfalls & Traps
Pitfall #1: Failing to advance a generation ID when filters or sorting change
Pitfall #2: Not deduplicating items before appending to the list
Pitfall #3: Triggering fetch calls on every ScrollController notification