← Articles

Handling pagination in Flutter

By Ann Tech · 2 September 2025

Pagination prevents loading thousands of records at once. Instead of fetching all orders upfront, you load a page, let the user scroll, then load the next. Here is how to implement cursor-based pagination in Flutter.

The two patterns

Offset pagination: load items 0-20, then 20-40. Simple but slow for large datasets.

Cursor pagination: load items after a specific ID or timestamp. Fast and consistent — items added during scrolling don't cause duplicates or gaps.

State model

class PaginatedState<T> {
  const PaginatedState({
    this.items = const [],
    this.isLoading = false,
    this.isLoadingMore = false,
    this.hasMore = true,
    this.error,
  });

  final List<T> items;
  final bool isLoading;
  final bool isLoadingMore;
  final bool hasMore;
  final String? error;

  PaginatedState<T> copyWith({List<T>? items, bool? isLoading, bool? isLoadingMore, bool? hasMore, String? error}) {
    return PaginatedState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
      isLoadingMore: isLoadingMore ?? this.isLoadingMore,
      hasMore: hasMore ?? this.hasMore,
      error: error,
    );
  }
}

BLoC implementation

class OrdersBloc extends Bloc<OrdersEvent, PaginatedState<Order>> {
  OrdersBloc(this._repo) : super(const PaginatedState()) {
    on<LoadOrders>(_onLoad);
    on<LoadMoreOrders>(_onLoadMore);
  }

  final OrderRepository _repo;
  String? _nextCursor;

  Future<void> _onLoad(LoadOrders event, Emitter emit) async {
    emit(state.copyWith(isLoading: true));
    try {
      final page = await _repo.getOrders(cursor: null, limit: 20);
      _nextCursor = page.nextCursor;
      emit(PaginatedState(items: page.items, hasMore: page.nextCursor != null));
    } catch (e) {
      emit(state.copyWith(isLoading: false, error: e.toString()));
    }
  }

  Future<void> _onLoadMore(LoadMoreOrders event, Emitter emit) async {
    if (state.isLoadingMore || !state.hasMore) return;
    emit(state.copyWith(isLoadingMore: true));
    try {
      final page = await _repo.getOrders(cursor: _nextCursor, limit: 20);
      _nextCursor = page.nextCursor;
      emit(state.copyWith(
        items: [...state.items, ...page.items],
        isLoadingMore: false,
        hasMore: page.nextCursor != null,
      ));
    } catch (e) {
      emit(state.copyWith(isLoadingMore: false, error: e.toString()));
    }
  }
}

Scroll listener

class _OrdersScreenState extends State<OrdersScreen> {
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
    context.read<OrdersBloc>().add(const LoadOrders());
  }

  void _onScroll() {
    final pos = _scrollController.position;
    if (pos.pixels >= pos.maxScrollExtent - 200) {
      context.read<OrdersBloc>().add(const LoadMoreOrders());
    }
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<OrdersBloc, PaginatedState<Order>>(
      builder: (context, state) {
        if (state.isLoading) return const Center(child: CircularProgressIndicator());
        return RefreshIndicator(
          onRefresh: () async => context.read<OrdersBloc>().add(const LoadOrders()),
          child: ListView.builder(
            controller: _scrollController,
            itemCount: state.items.length + (state.hasMore ? 1 : 0),
            itemBuilder: (_, i) {
              if (i == state.items.length) {
                return state.isLoadingMore
                    ? const Center(child: Padding(
                        padding: EdgeInsets.all(16),
                        child: CircularProgressIndicator(),
                      ))
                    : const SizedBox.shrink();
              }
              return OrderCard(order: state.items[i]);
            },
          ),
        );
      },
    );
  }
}

Firestore pagination

class OrderRepository {
  final _db = FirebaseFirestore.instance;
  DocumentSnapshot? _lastDoc;

  Future<Page<Order>> getOrders({int limit = 20}) async {
    Query query = _db
        .collection('orders')
        .orderBy('createdAt', descending: true)
        .limit(limit);

    if (_lastDoc != null) query = query.startAfterDocument(_lastDoc!);

    final snap = await query.get();
    if (snap.docs.isNotEmpty) _lastDoc = snap.docs.last;

    return Page(
      items: snap.docs.map((d) => Order.fromFirestore(d)).toList(),
      hasMore: snap.docs.length == limit,
    );
  }

  void reset() => _lastDoc = null;
}

infinite_scroll_pagination package

dependencies:
  infinite_scroll_pagination: ^4.0.0
final _pagingController = PagingController<int, Order>(firstPageKey: 0);

@override
void initState() {
  super.initState();
  _pagingController.addPageRequestListener(_fetchPage);
}

@override
void dispose() {
  _pagingController.dispose();
  super.dispose();
}

Future<void> _fetchPage(int pageKey) async {
  try {
    final items = await api.getOrders(page: pageKey, limit: 20);
    if (items.length < 20) {
      _pagingController.appendLastPage(items);
    } else {
      _pagingController.appendPage(items, pageKey + 1);
    }
  } catch (error) {
    _pagingController.error = error;
  }
}

// In build:
PagedListView<int, Order>(
  pagingController: _pagingController,
  builderDelegate: PagedChildBuilderDelegate<Order>(
    itemBuilder: (context, order, _) => OrderCard(order: order),
    firstPageErrorIndicatorBuilder: (_) => ErrorView(onRetry: _pagingController.refresh),
    noItemsFoundIndicatorBuilder: (_) => const EmptyOrdersView(),
  ),
)

Common pitfalls

Triggering load more too late. Triggering only at pixels == maxScrollExtent means the user sees the list end before new items load. Trigger 200–400px before the bottom.

Forgetting pull-to-refresh. Without it, users can never see items added after they opened the screen. Always wrap in RefreshIndicator.

Not disposing the paging controller. Call _pagingController.dispose() in dispose() to avoid leaks.

Forgetting to reset the cursor on refresh. When pulling to refresh, the BLoC should reset _nextCursor to null so the next load starts from the beginning.

Sign in to like, dislike, or report.

Handling pagination in Flutter — ANN Tech