usePaginatedComputedState
Drives cursor-based pagination: it loads the first page automatically, appends pages on demand via loadMore(), and refreshes from the start when keys change. It returns a MutablePaginatedComputedState<T, C> holding the accumulated items, the next cursor, and isLoading / hasMore / error flags.
class FeedScreen extends HookWidget {
const FeedScreen({super.key, required this.api});
final FeedApi api;
Widget build(BuildContext context) {
// C is String? so null can represent "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, // <- drop items that overlap across pages
);
final items = postsState.items;
if (items == null) return const CircularProgressIndicator(); // <- before the first load
return ListView.builder(
itemCount: items.length + (postsState.hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i < items.length) return Text(items[i].title);
// Reaching the end triggers the next page; loadMore is a no-op while one is in flight.
unawaited(postsState.loadMore());
return const Center(child: CircularProgressIndicator());
},
);
}
}
Signature
MutablePaginatedComputedState<T, C> usePaginatedComputedState<T, C>(
Future<PaginatedPage<T, C>> Function(C cursor) compute, {
required C initialCursor,
bool shouldCompute = true,
bool clearOnShouldComputeFalse = false,
HookKeys keys = hookKeysEmpty,
Duration debounceDuration = Duration.zero,
Object Function(T item)? deduplicateBy,
});
compute- called with the cursor for the next page; returns aPaginatedPage<T, C>. AnullnextCursoron the returned page marks the last page.initialCursor- the cursor for the first page. Captured on the first build and ignored afterwards.shouldCompute(trueby default) - gates the automatic loads only (first page +keys-triggered refresh). See the caveat below - its contract is weaker thanuseAutoComputedState's.clearOnShouldComputeFalse(falseby default) - whentrue, closing the gate (shouldCompute: false) also clears the state and cancels the in-flight load.keys- any change triggersrefresh()frominitialCursor. Items stay visible during the reload.debounceDuration(Duration.zeroby default) - delays the first-page load after akeyschange. Does not affectloadMore.deduplicateBy- optional identifier extractor; incoming items whose identifier matches an already-collected item are dropped before being appended. First occurrence wins.
C is opaque to the hook, so it can model any pagination scheme - an int offset, an int page number, or a nullable String? continuation token.
PaginatedPage
What compute returns - one page of items plus the cursor for the next page:
PaginatedPage(items: [...], nextCursor: 2); // <- more pages available
PaginatedPage.last(items: [...]); // <- final page, nextCursor is null
MutablePaginatedComputedState
| Member | Meaning |
|---|---|
items | List<T>? - null before the first successful load and after clear(). Stays populated across loadMore and across refresh(clearCache: false) until the first new page replaces it. |
cursor | The cursor for the next loadMore. Starts at initialCursor, advances through nextCursor values, stays at its last value once the end is reached. |
hasMore | false once a page returned nextCursor == null. Reset by refresh() and by a keys change. |
isLoading | true whenever any load (first page, loadMore, or refresh) is in flight. |
error | The last failed load's exception. Cleared when the next load starts. |
isInitialized / hasError | Aliases for items != null / error != null. |
loadMore() | Loads the next page. No-op when hasMore is false; while a load is in flight, returns the in-flight operation rather than starting a second one. |
refresh({bool clearCache = false}) | Cancels any in-flight load, resets cursor / hasMore / error, then loads the first page. |
clear() | Cancels any in-flight load and resets every field to its initial state. Does not reload. |
Use cases
-
Any endpoint that returns pages: infinite-scroll feeds, paginated search, chat history, notification lists. This is the default - don't hand-roll
useState<List<T>>plushasMore,isLoading, and a cursor. -
Different backend pagination styles, by choosing
C. Offset-based, where the cursor is the next offset:class UserList extends HookWidget {const UserList({super.key, required this.api});final FeedApi api;Widget build(BuildContext context) {// Offset-based: the cursor is the next offset; null nextCursor ends pagination.final usersState = usePaginatedComputedState<Post, int>(initialCursor: 0,(offset) async {final items = await api.users(offset: offset, limit: 20);return PaginatedPage(items: items,nextCursor: items.length < 20 ? null : offset + items.length,);},);return RefreshIndicator(// Default clearCache: false keeps items visible during the reload - no flicker.onRefresh: usersState.refresh,child: ListView(children: [for (final user in usersState.items ?? const <Post>[]) Text(user.title)],),);}} -
Paginated search, where the query is a key and
debounceDurationdelays the first-page load:class PostSearch extends HookWidget {const PostSearch({super.key, required this.api});final FeedApi api;Widget build(BuildContext context) {final query = useFieldState();// keys re-run the search from the first page; debounce delays that first load.final resultsState = usePaginatedComputedState<Post, String?>(initialCursor: null,(token) async {final response = await api.feed(pageToken: token);return PaginatedPage(items: response.items, nextCursor: response.nextPageToken);},keys: [query.value],debounceDuration: const Duration(milliseconds: 300),);return Column(children: [TextField(onChanged: (it) => query.value = it),for (final post in resultsState.items ?? const <Post>[]) Text(post.title),],);}}
Expose the MutablePaginatedComputedState<T, C> directly as a field on your State class - the View needs loadMore / refresh, so don't project each field separately. The PaginatedComputedStateWrapper widget wires the scroll listener and pull-to-refresh to it for a two-line infinite-scroll list.
Caveats
-
shouldCompute: falsedoes not clear or cancel - unlikeuseAutoComputedState. It only skips the automatic loads; an in-flight load finishes,itemsstay visible, and manualloadMore()/refresh()still work. PassclearOnShouldComputeFalse: trueif you need the state wiped when the gate closes. -
itemsis read-only - there is no setter, on purpose. For optimistic add/edit/delete, keep a separate override layer in auseStateand overlay it at render time; settle it after the server confirms.// DON'T - there is no items setter, and the hook owns its bufferstate.items = [...state.items, newPost];// DO - overlay a local layer, then refresh() once the server confirmsfinal prependedState = useState<List<Post>>(const []);final visible = useMemoized(() => [...prependedState.value, ...?postsState.items],[prependedState.value, postsState.items],); -
initialCursoris not reactive - it is captured once. For a runtime-dynamic starting point (a "jump to date" UI), wrap the whole hook inuseKeyedso the entire state is recreated when the start changes. -
Use the default
refresh(clearCache: false)for pull-to-refresh and keys-triggered reloads - items stay on screen and are replaced page-by-page, with no flicker.clearCache: truedropsitemstonullfirst; reserve it for switching to a fundamentally different dataset. -
Forgetting
deduplicateByon a token-based API that returns overlapping pages shows the boundary item twice. AdddeduplicateBy: (it) => it.id. -
Auto-triggered first-load errors propagate as unhandled zone errors (the let-it-crash default), with the failure also stored in
error. Toawaita failure (e.g. in a test), passshouldCompute: falseand callrefresh()manually. -
For a live, bidirectional list - a watch stream prepending new items while the user scrolls back through history - this hook is the wrong shape; it is one-directional forward pull. Split the two directions (paginate history, subscribe to the live tail with
useMemoizedStream) and merge at render time.
See also
- useAutoComputedState - for a single-shot
Future<T>rather than paged data - useKeyed - recreate the whole state when
initialCursormust change - useDebounced - debounce a search query feeding
keys - useMemoizedStream - the live-tail half of a bidirectional list