Skill: Pagination
Overview
Pagination loads a list in pages instead of all at once. In Flutter the state lives in an AsyncNotifier that accumulates items and tracks hasMore; the list widget triggers loadMore() as the user approaches the end. Prefer cursor-based paging — offset paging silently skips or duplicates rows when the underlying data shifts between requests.
Use Cases
- Feeds, search results, transaction history, chat backlog.
- Any list that can grow beyond a few hundred rows.
- Infinite scroll with pull-to-refresh.
Best Practices
- Cursor over offset. The server returns
nextCursor;nullmeans the end. Offset paging on a mutating collection produces duplicates and gaps. - Model a
Page<T>domain type (items,nextCursor) so the repository returns something typed. - Keep four distinct UI states, not two: first-page loading, loaded-with-more, loading-more, and error-on-a-later-page (which must keep the already-loaded items visible with a retry row).
- Guard against concurrent
loadMore. Track an in-flight flag; a fast scroll fires the trigger many times, and without the guard you fetch the same page repeatedly. - De-duplicate on append by id — retries and shifting data will otherwise show the same row twice.
- Pull-to-refresh resets the cursor and replaces the list, and must cancel or ignore any in-flight page from before the refresh.
- Use
ListView.builderwith a sentinel item for the loading/retry row rather than aScrollControlleroffset calculation — it works with any list height and with slivers. - Give
itemExtentorprototypeItemwhen rows are uniform; it removes per-frame layout cost. - Page size is a policy: large enough to fill a screen and a bit more (commonly 20–50), tuned against payload size, not guessed per screen.
Anti-Patterns
- ❌ Loading the whole collection and paginating client-side.
- ❌ Firing
loadMore()without an in-flight guard. - ❌ Appending without de-duplication.
- ❌ Replacing the whole list with a spinner when page 3 fails.
- ❌ Offset paging against a feed that inserts at the head.
- ❌ Building the trigger from raw
ScrollController.offsetarithmetic. - ❌ Keeping unbounded pages in memory for an effectively infinite feed.
Checklist
- [ ] Cursor-based where the API supports it; offset only with a stated reason.
- [ ]
loadMoreis guarded against concurrent invocation. - [ ] Appends are de-duplicated by id.
- [ ] First-page error, later-page error, empty, and end-of-list are each handled distinctly.
- [ ] Pull-to-refresh resets the cursor and discards stale in-flight pages.
- [ ] Trigger is a sentinel item in
ListView.builder, not offset math. - [ ]
itemExtent/prototypeItemset for uniform rows. - [ ] Notifier has tests for: append, duplicate page, error-on-page-2, refresh-during-load.
Dart Examples
dart
// domain/entities/page.dart
class Page<T> {
const Page({required this.items, this.nextCursor});
final List<T> items;
final String? nextCursor;
bool get hasMore => nextCursor != null;
}dart
// presentation/articles_feed_notifier.dart
class FeedState {
const FeedState({
this.items = const [],
this.cursor,
this.hasMore = true,
this.isLoadingMore = false,
this.loadMoreError,
});
final List<Article> items;
final String? cursor;
final bool hasMore;
final bool isLoadingMore;
final Object? loadMoreError;
FeedState copyWith({
List<Article>? items,
String? cursor,
bool? hasMore,
bool? isLoadingMore,
Object? loadMoreError,
}) =>
FeedState(
items: items ?? this.items,
cursor: cursor ?? this.cursor,
hasMore: hasMore ?? this.hasMore,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
loadMoreError: loadMoreError,
);
}
class FeedNotifier extends AsyncNotifier<FeedState> {
@override
Future<FeedState> build() async {
final page = await ref.watch(fetchFeedProvider)(cursor: null);
return FeedState(items: page.items, cursor: page.nextCursor, hasMore: page.hasMore);
}
Future<void> loadMore() async {
final current = state.valueOrNull;
// The guard: a fast scroll fires this many times per second.
if (current == null || current.isLoadingMore || !current.hasMore) return;
state = AsyncData(current.copyWith(isLoadingMore: true, loadMoreError: null));
try {
final page = await ref.read(fetchFeedProvider)(cursor: current.cursor);
final seen = current.items.map((a) => a.id).toSet();
state = AsyncData(
current.copyWith(
items: [...current.items, ...page.items.where((a) => seen.add(a.id))],
cursor: page.nextCursor,
hasMore: page.hasMore,
isLoadingMore: false,
),
);
} catch (e) {
// Keep the loaded items; only the tail shows an error.
state = AsyncData(current.copyWith(isLoadingMore: false, loadMoreError: e));
}
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(build);
}
}dart
// presentation/feed_list.dart — sentinel item drives the trigger
ListView.builder(
itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index < state.items.length) return ArticleRow(article: state.items[index]);
if (state.loadMoreError != null) {
return RetryRow(onRetry: () => ref.read(feedProvider.notifier).loadMore());
}
ref.read(feedProvider.notifier).loadMore();
return const LoadingRow();
},
);Common Interview Questions
- Why does offset paging break on a feed that inserts at the head?
- What happens without an in-flight guard on
loadMore, and how do you observe it? - How should a failure on page 3 differ from a failure on page 1, in the UI?
- Why de-duplicate on append if the server is correct?
- How do you handle a refresh that lands while a
loadMoreis still in flight?
AI Implementation Notes
- Generate the in-flight guard and the de-duplication together with
loadMore— they are not optional refinements. - Never collapse a later-page error into the screen-level error state.
- Prefer a cursor; if the API is offset-only, say so in a comment and note the duplication risk.
infinite_scroll_paginationis an acceptable shortcut for the UI plumbing, but state must still live in a notifier the tests can drive.- iOS counterpart:
../ios/pagination.md. - Related:
rest_api.md,../../performance/flutter/rendering_optimization.md.