Skip to main content

PaginatedComputedStateWrapper

Wires a MutablePaginatedComputedState to a scrollable: it calls loadMore() when the list nears its end and adds pull-to-refresh. Unlike the computed wrappers, it does not branch the UI for you - builder receives the current items and a loadingMore flag, and you render every state (first load, empty, error, list) from those.

class FeedView extends HookWidget {
const FeedView({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,
// The wrapper drives loadMore on scroll and wires pull-to-refresh; you render the list.
builder: (context, items, loadingMore) {
if (items == null) return const Center(child: CircularProgressIndicator()); // <- first load
if (items.isEmpty) return const Center(child: Text('No posts yet'));
return ListView.builder(
itemCount: items.length + (loadingMore ? 1 : 0),
itemBuilder: (context, i) => i < items.length
? Text(items[i].title)
: const Center(child: CircularProgressIndicator()), // <- trailing loadMore spinner
);
},
);
}
}

Constructor

PaginatedComputedStateWrapper<T, C>({
Key? key,
required MutablePaginatedComputedState<T, C> state,
required Widget Function(BuildContext context, List<T>? items, bool loadingMore) builder,
bool refreshable = true,
double loadMoreThreshold = 200,
});
  • state - the MutablePaginatedComputedState<T, C> from usePaginatedComputedState.
  • builder - renders the list. Receives items (null until the first successful load) and loadingMore (true while a follow-up load runs on top of already-visible items). loadingMore is narrower than state.isLoading: the latter is true for any in-flight load including the first page, while loadingMore is true only for a loadMore or refresh that runs on top of items already on screen - which is why a first-page load shows in items == null, not in loadingMore. It must return a scrollable for end-of-list detection to fire.
  • refreshable (true by default) - wraps the result in a RefreshIndicator whose onRefresh calls state.refresh(). Set false to drop pull-to-refresh while keeping load-more-on-scroll.
  • loadMoreThreshold (200 by default) - the distance in logical pixels from the bottom at which loadMore() is triggered.

How it differs from the computed wrappers

The compute wrappers own the loading/failure/empty branching. This one does not: it only drives loadMore and refresh and hands you items + loadingMore. You decide what items == null, an empty list, and state.error each render. That is deliberate - paginated UIs need the error and the trailing load-more spinner inline with the list, which a fixed set of builders can't express.

The mapping is direct:

  • items == null - the first page is still loading. Render a full-screen loader.
  • items!.isEmpty - loaded, nothing to show. Render an empty placeholder.
  • loadingMore - a loadMore or refresh is running on top of visible items. Render a trailing spinner.
  • state.error / state.hasError - the last load failed. Read it from state; it is not passed to builder.

Use cases

  • Infinite-scroll lists: feeds, search results, chat history, notifications. This is the two-line path - pair usePaginatedComputedState with this widget instead of hand-wiring a ScrollController listener and a RefreshIndicator.

  • A paginated list without pull-to-refresh (a read-only log, a one-shot result set) by passing refreshable: false, and tuning how eagerly the next page loads via loadMoreThreshold:

    class FixedFeedView extends HookWidget {
    const FixedFeedView({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,
    refreshable: false, // <- no RefreshIndicator; loadMore-on-scroll still fires
    loadMoreThreshold: 400, // <- start the next page 400px before the end
    builder: (context, items, loadingMore) => ListView(
    children: [for (final post in items ?? const <Post>[]) Text(post.title)],
    ),
    );
    }
    }

Caveats

  • builder must return a scrollable. End-of-list detection rides on the ScrollNotifications bubbling from the child, so a non-scrolling widget (a Column, a short Wrap) never triggers loadMore. Use ListView / CustomScrollView / GridView.

  • The error is not a builder argument - read it from state.error / state.hasError. There is no failedBuilder here; surface failures from the state, typically as a trailing item beneath the list.

  • loadMore fires only while there is more and nothing is in flight. The internal scroll handler skips the call when hasMore is false, a load is already running, or the state currently has an error - so a failed page does not auto-retry on every scroll tick. Recovery is an explicit refresh() or loadMore().

  • Pull-to-refresh swallows the benign cancellation. onRefresh calls state.refresh() and discards a ComputedStateRefreshCancelled so the RefreshIndicator resolves cleanly when a refresh is superseded. You don't need to handle that yourself when using this widget; you do if you call refresh() directly elsewhere.

See also