Skip to main content

Flutter infinite scroll and pagination with hooks

A paginated list re-invents the same machinery every time: load the first page, append more as the user scrolls, don't fire two loads at once, cancel the old load when the query changes, debounce search, refresh on pull-down. usePaginatedComputedState is that machinery. It is the default for any cursor-based list - infinite-scroll feeds, paginated search, chat history.

You give it a compute that takes a cursor and returns one PaginatedPage - the items plus the cursor for the next page. A null nextCursor means "no more pages". Pair it with PaginatedComputedStateWrapper and infinite scroll plus pull-to-refresh is two lines:

class FeedScreen extends HookWidget {
const FeedScreen({super.key, required this.api});

final FeedApi api;


Widget build(BuildContext context) {
// Token cursor: C is String? so null means "no token yet" on the first call.
final postsState = usePaginatedComputedState<Post, String?>(
initialCursor: null,
(token) async {
final response = await api.feed(pageToken: token);
return PaginatedPage(items: response.items, nextCursor: response.nextPageToken);
},
deduplicateBy: (post) => post.id,
);

// The wrapper wires scroll-to-load-more and pull-to-refresh; the builder owns
// every visible state (loading, empty, the list itself).
return PaginatedComputedStateWrapper<Post, String?>(
state: postsState,
builder: (context, items, loadingMore) {
if (items == null) return const Center(child: CircularProgressIndicator());
if (items.isEmpty) return const Center(child: Text('No posts yet'));
return ListView.builder(
// One extra row for the bottom spinner while the next page loads.
itemCount: items.length + (loadingMore ? 1 : 0),
itemBuilder: (context, i) => i < items.length
? ListTile(title: Text(items[i].title))
: const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
);
},
);
}
}

The state exposes items (null until the first successful load), hasMore, isLoading, and error, plus the actions loadMore(), refresh(), and clear(). Do not hand-roll a useState<List<T>> + hasMore + isLoading + cursor - you will re-implement deduplication and cancellation, badly.

Cursors

The cursor C is opaque to the hook - pick the type that matches your backend. The token example above uses String? so null can mean "no token yet". Offset- and page-based APIs use an int:

class OffsetList extends HookWidget {
const OffsetList({super.key, required this.api});

final FeedApi api;


Widget build(BuildContext context) {
// Offset cursor: the next cursor is the running offset; a short page ends it.
final postsState = usePaginatedComputedState<Post, int>(
initialCursor: 0,
(offset) async {
final items = await api.page(offset: offset, limit: 20);
return PaginatedPage(
items: items,
nextCursor: items.length < 20 ? null : offset + items.length,
);
},
);

return PaginatedComputedStateWrapper<Post, int>(
state: postsState,
builder: (context, items, loadingMore) {
if (items == null) return const Center(child: CircularProgressIndicator());
return ListView(children: [for (final post in items) ListTile(title: Text(post.title))]);
},
);
}
}

initialCursor is captured once on the first build and ignored afterwards. For a runtime-dynamic starting point - a "jump to date" UI - wrap the whole hook in useKeyed so the state is recreated when the start changes.

The wrapper's contract

PaginatedComputedStateWrapper wires a scroll listener and a RefreshIndicator to the state. Its builder receives (context, items, loadingMore) and the caller owns all rendering - the wrapper draws no spinners, no empty state, no errors. That is deliberate: every screen's idea of "empty" is different, so forcing builders would mean a dozen parameters and still miss cases.

Three things the builder must handle:

  • items == null - before the first load. Render your top-level loader here. If you return const SizedBox.shrink(), the user sees a blank screen.
  • items.isEmpty - loaded, nothing there. Render your empty state.
  • loadingMore - a follow-up page is loading on top of visible items. Render a bottom spinner row.

The builder must return a scrollable (ListView, CustomScrollView, GridView) - the scroll notifications that trigger loadMore come from that child. loadMoreThreshold (default 200) is how many pixels from the end the load fires; set refreshable: false to drop the pull-to-refresh (e.g. a reverse-scrolling chat).

Errors don't loop

When a loadMore fails, the wrapper suppresses auto-loading while error != null - otherwise a failing page at the bottom of an infinite scroll would retry forever. The user has to trigger the retry, so render an explicit affordance. Calling loadMore() clears the error and re-enables auto-loading:

class FeedWithRetry extends HookWidget {
const FeedWithRetry({super.key, required this.api});

final FeedApi api;


Widget build(BuildContext context) {
final postsState = usePaginatedComputedState<Post, String?>(
initialCursor: null,
(token) async {
final response = await api.feed(pageToken: token);
return PaginatedPage(items: response.items, nextCursor: response.nextPageToken);
},
);

return PaginatedComputedStateWrapper<Post, String?>(
state: postsState,
builder: (context, items, loadingMore) {
// First-load failure: no items yet, render a full-screen retry.
if (items == null) {
if (postsState.hasError) {
return Center(child: TextButton(onPressed: postsState.refresh, child: const Text('Retry')));
}
return const Center(child: CircularProgressIndicator());
}
// Auto-loadMore is suppressed while error != null, so render an explicit
// trailing retry row instead of an endless failing scroll.
final hasTrailingRow = loadingMore || postsState.hasError;
return ListView.builder(
itemCount: items.length + (hasTrailingRow ? 1 : 0),
itemBuilder: (context, i) {
if (i < items.length) return ListTile(title: Text(items[i].title));
if (postsState.hasError) {
// loadMore clears the error when it starts, re-enabling auto-loading.
return TextButton(onPressed: postsState.loadMore, child: const Text('Retry'));
}
return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));
},
);
},
);
}
}

refresh keeps items visible

refresh() cancels any in-flight load, resets the cursor to initialCursor, and reloads the first page. Its clearCache parameter defaults to false, which is what pull-to-refresh and keys-triggered reloads want: the old items stay on screen and are replaced by the first page of the new load - no flicker. Pass clearCache: true only when you want a deliberate blank slate.

keys triggers a refresh() on every change (items stay visible), and debounceDuration delays that first-page load - the combination that drives paginated search. shouldCompute gates the automatic loads, but note its contract is weaker than useAutoComputedState's:

  • An in-flight load is not cancelled when shouldCompute turns false - it finishes.
  • items are not cleared - previous data stays visible.
  • Manual loadMore() / refresh() are never gated.

Pass clearOnShouldComputeFalse: true if you do want the state wiped when the gate closes (e.g. on logout, to drop the previous user's data).

Without the wrapper

For a "Load more" button or any non-scroll-driven pagination, skip the wrapper and drive loadMore yourself off hasMore and isLoading:

class LoadMoreButton extends HookWidget {
const LoadMoreButton({super.key, required this.api});

final FeedApi api;


Widget build(BuildContext context) {
final postsState = usePaginatedComputedState<Post, String?>(
initialCursor: null,
(token) async {
final response = await api.feed(pageToken: token);
return PaginatedPage(items: response.items, nextCursor: response.nextPageToken);
},
);

// Without the wrapper: drive loadMore yourself from a button.
return ListView(
children: [
for (final post in postsState.items ?? const <Post>[]) ListTile(title: Text(post.title)),
if (postsState.hasMore)
TextButton(
onPressed: postsState.isLoading ? null : postsState.loadMore,
child: postsState.isLoading ? const CircularProgressIndicator() : const Text('Load more'),
),
],
);
}
}

Expose the state, don't unpack it

In the Screen / State / View pattern, call the hook in the state hook and expose the MutablePaginatedComputedState<T, C> as a single field. The View needs loadMore and refresh, so passing the whole mutable state through is correct - don't project items, isLoading, and loadMore as separate fields.

items is read-only on purpose - the hook owns its buffer. For optimistic add/edit/delete, keep a local override layer (a useState of edits or deletions) and overlay it at render time with a useMemoized; reconcile by calling refresh() after the server confirms. The pattern is worked out in the skill's paginated reference.

caution

For token-based APIs whose adjacent pages can overlap, pass deduplicateBy: (it) => it.id. Without it the same item appears twice near a page boundary. usePaginatedComputedState is one-directional forward pull - it does not fit a live chat where a stream prepends new messages while the user scrolls back. For that, run a backward-paginated state and a useMemoizedStream tail and merge them.

See also