Skip to main content

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 a PaginatedPage<T, C>. A null nextCursor on the returned page marks the last page.
  • initialCursor - the cursor for the first page. Captured on the first build and ignored afterwards.
  • shouldCompute (true by default) - gates the automatic loads only (first page + keys-triggered refresh). See the caveat below - its contract is weaker than useAutoComputedState's.
  • clearOnShouldComputeFalse (false by default) - when true, closing the gate (shouldCompute: false) also clears the state and cancels the in-flight load.
  • keys - any change triggers refresh() from initialCursor. Items stay visible during the reload.
  • debounceDuration (Duration.zero by default) - delays the first-page load after a keys change. Does not affect loadMore.
  • 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

MemberMeaning
itemsList<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.
cursorThe cursor for the next loadMore. Starts at initialCursor, advances through nextCursor values, stays at its last value once the end is reached.
hasMorefalse once a page returned nextCursor == null. Reset by refresh() and by a keys change.
isLoadingtrue whenever any load (first page, loadMore, or refresh) is in flight.
errorThe last failed load's exception. Cleared when the next load starts.
isInitialized / hasErrorAliases 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>> plus hasMore, 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 debounceDuration delays 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: false does not clear or cancel - unlike useAutoComputedState. It only skips the automatic loads; an in-flight load finishes, items stay visible, and manual loadMore() / refresh() still work. Pass clearOnShouldComputeFalse: true if you need the state wiped when the gate closes.

  • items is read-only - there is no setter, on purpose. For optimistic add/edit/delete, keep a separate override layer in a useState and overlay it at render time; settle it after the server confirms.

    // DON'T - there is no items setter, and the hook owns its buffer
    state.items = [...state.items, newPost];

    // DO - overlay a local layer, then refresh() once the server confirms
    final prependedState = useState<List<Post>>(const []);
    final visible = useMemoized(
    () => [...prependedState.value, ...?postsState.items],
    [prependedState.value, postsState.items],
    );
  • initialCursor is not reactive - it is captured once. For a runtime-dynamic starting point (a "jump to date" UI), wrap the whole hook in useKeyed so 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: true drops items to null first; reserve it for switching to a fundamentally different dataset.

  • Forgetting deduplicateBy on a token-based API that returns overlapping pages shows the boundary item twice. Add deduplicateBy: (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. To await a failure (e.g. in a test), pass shouldCompute: false and call refresh() 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