Skip to main content
Back to all tutorials
Tested: Flutter 3.24.3 • Dart 3.5.3
T06.02 Verified September 2026
Pagination Infinite Scroll Request Identity Deduplication ListView

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 Tested Flutter Code

main.dart (Flutter 3.x+ ready)
lib/main.dart Copy & Paste into a new Flutter project
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

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.

Official Flutter References & Standards