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.