Pagination
A field called with different arguments is a different cache entry — unless a
type policy says otherwise. For a cursor
connection, RelayStylePagination does: every page of launches(first: 20, after: …) is
merged into one growing list in the cache (Apollo’s relayStylePagination), and
PaginatedQueryBuilder reads that list and asks for the next page.
One list per connection: RelayStylePagination
Section titled “One list per connection: RelayStylePagination”Register the policy once, on the client, under the generated class the field is read through:
const Map<Type, TypePolicy> typePolicies = { Query: TypePolicy(fields: {'launches': RelayStylePagination()}),};
SlingClient<Query>(schema: slingSchema, typePolicies: typePolicies, /* … */);What changes in the cache:
- One entry per key arguments.
first,after,lastandbeforeare not part of the cache key; every other argument is (filter,orderBy).launches(first: 20)andlaunches(first: 20, after: "…")are stored together atROOT_QUERY.launches;launches(first: 20, filter: {status: SUCCESS})is another merged list. PassRelayStylePagination(keyArgs: ['filter'])to choose them yourself. - Pages append. A page
aftera cursor is appended tonodes/edges(prepended forbefore);pageInfo.endCursor/hasNextPagecome from the last page,startCursor/hasPreviousPagefrom the first,totalCountfrom the latest. Each page still goes out under its own alias, so two pages in one request never clash. - A page not merged yet is a miss. The entry remembers which pages it holds, so reading
launches(first: 20, after: c)renders a skeleton and fetches exactly that page until it has been merged; the pages already there stay on screen. - A refresh starts over. A fetch of a whole selection —
refetch(), pull to refresh,cacheAndNetwork, a stalemaxAge— sends the connection’s first page only, and a first page replaces the merged list. After a refresh the list is back to page one. - New fields fill, they do not re-merge. A row reading a field the entry’s entities lack fetches it for every page held, in one request, and only writes the entities: the list keeps its pages (entities no page returns any more leave it).
Everything else is the ordinary cache: the merged list is one root field, so it persists
with SQLite (and the next page continues it after a
restart), ListRules and cacheScope.list edit it, gc and changesSince see it.
PaginatedQueryBuilder
Section titled “PaginatedQueryBuilder”The example’s list body is a PaginatedQueryBuilder<Query, Launch>:
class _LaunchesScreenState extends State<LaunchesScreen> { static const pageSize = 20; final _pagination = PaginationController(); // the page being loaded, if any
void _onSegmentChanged(LaunchStatus? status) { setState(() => _status = status); _pagination.reset(); // forget a page loading for the previous filter }
@override Widget build(BuildContext context) => PaginatedQueryBuilder<Query, Launch>( controller: _pagination, // Runs for the merged list (and the page loading), every build. Read every field here. page: (query, after) { final page = query.launches(first: pageSize, after: after, filter: filter); return ConnectionPage( nodes: page?.nodes, hasNextPage: page?.pageInfo?.hasNextPage, endCursor: page?.pageInfo?.endCursor, totalCount: page?.totalCount, ); }, builder: (context, state) => CustomScrollView(slivers: [ CupertinoSliverRefreshControl(onRefresh: state.refetch), SliverList.builder( itemCount: state.items.length, itemBuilder: (_, i) => LaunchRow(state.items[i]), ), SliverToBoxAdapter( child: state.hasMore ? CupertinoButton.filled( onPressed: state.loadMore, child: Text('Load more (${state.items.length} / ${state.totalCount})'), ) : state.isLoading ? const CupertinoActivityIndicator() : Text('${state.items.length} launches'), ), ]), );}pagemaps the query root and a cursor to aConnectionPage— the generated connection types are plain classes, so you name the four fields once. The widget calls it withnull(the first page, i.e. the merged list) and, while a page is loading, with that page’s cursor.PaginatedStatehands the builderitems(the merged list, then the skeleton of the page loading),hasMore,totalCount,loadMore(),refetch(), plusisLoading/hasMissingData/isSkeleton/isStale/errorandrevalidate()from the underlyingQueryBuilder.QueryBuilder’s options —fetchPolicy,maxAge,errorPolicy,timeout,debugLabel,scheduler— arePaginatedQueryBuilder’s too, passed to it as is.PaginationControllerholds the cursor of the page being loaded. It is optional: pass your own when something outside the builder mustreset()it (a filter or sort change); otherwise the widget keeps a private one.
The field needs the policy: without it the next page would land in an entry of its own and
the list would not grow (PaginatedQueryBuilder asserts in debug builds).
What happens on the wire:
| Interaction | Request |
|---|---|
| First frame | launches_a: launches(first: 20) — plus the header’s company and stats, batched |
state.loadMore() |
launches_b: launches(first: 20, after: "…") — only the next page, with every field the rows read; it is merged into ROOT_QUERY.launches |
state.refetch() (pull to refresh) |
launches_a again — the first page only: the list starts over from page one |
| Another segment, then back | the other filter is another merged list; coming back shows this one, every loaded page included, from the cache |
query ($first: Int, $after: String) { launches_jrgmxa049n58w: launches(first: $first, after: $after) { __typename nodes { __typename status name date id rocket { __typename name } } pageInfo { __typename hasNextPage endCursor } totalCount }}Try it
Section titled “Try it”Pages of five:
class LaunchPages extends StatelessWidget { const LaunchPages({super.key});
@override Widget build(BuildContext context) { return PaginatedQueryBuilder<Query, Launch>( // Runs for the merged list (and the page loading), in one build. page: (query, after) { final page = query.launches(first: 5, after: after); return ConnectionPage( nodes: page?.nodes, hasNextPage: page?.pageInfo?.hasNextPage, endCursor: page?.pageInfo?.endCursor, totalCount: page?.totalCount, ); }, builder: (context, state) => Column( children: [ Expanded( child: ListView( children: [for (final launch in state.items) LaunchLine(launch)], ), ), Row( children: [ CupertinoButton( onPressed: state.hasMore ? state.loadMore : null, child: Text( 'Load more (${state.items.length} / ' '${state.totalCount ?? '…'})', ), ), CupertinoButton( onPressed: state.refetch, child: const Text('Refresh'), ), ], ), ], ), ); }}- Load more: one more request, for the next five only (
after:the last cursor), merged into the list the first page started. - Refresh: one request for the first page only — a refresh resets the connection to page one, and the list shows five launches again.
The manual recipe
Section titled “The manual recipe”PaginatedQueryBuilder is thin — this is what it does, and what you would write for a
connection shape it does not fit (with the policy registered):
class _LaunchesScreenState extends State<LaunchesScreen> { static const pageSize = 20; String? _loading; // the cursor of the page on its way
@override Widget build(BuildContext context) => QueryBuilder<Query>( builder: (context, query, state) { final list = query.launches(first: pageSize); // the merged list final launches = [...?list?.nodes]; var hasMore = list?.pageInfo?.hasNextPage ?? false; final endCursor = list?.pageInfo?.endCursor; // read during build, see below final total = list?.totalCount;
if (_loading case final cursor?) { final next = query.launches(first: pageSize, after: cursor); if (next?.pageInfo?.hasNextPage == null) { launches.addAll(next?.nodes ?? const []); // not merged yet: its skeleton hasMore = false; } else { _loading = null; // merged: `list` has it now } }
return CustomScrollView(slivers: [ CupertinoSliverRefreshControl(onRefresh: state.refetch), SliverList.builder( itemCount: launches.length, itemBuilder: (_, i) => _LaunchRow(launches[i]), ), SliverToBoxAdapter( child: hasMore ? CupertinoButton.filled( onPressed: () => setState(() => _loading = endCursor), child: Text('Load more (${launches.length} / $total)'), ) : Text('${launches.length} launches'), ), ]); }, );}Offset pagination
Section titled “Offset pagination”Same idea with launchesPage(limit, offset), a page counter instead of cursors:
final pages = [ for (var i = 0; i < _pageCount; i++) query.launchesPage(limit: 20, offset: i * 20, filter: _filter, orderBy: _order),];final launches = [for (final p in pages) ...?p];Without a policy every page is its own cache entry: changing _filter or _order
changes the arguments, hence the alias, hence the entry — the old pages stay cached
(useful if the user toggles back), the new ones are fetched. To merge offset pages into one
list instead, give the field a custom policy whose
covers says which offsets the entry holds.
Nested connections
Section titled “Nested connections”Connections are ordinary fields, so they nest: rocket.launches(first: 5) inside a
launch detail is one more sub-selection in the same request.
final recent = launch.rocket?.launches(first: 5)?.nodes ?? const <Launch>[];To merge its pages too, register the policy under the class it is read through:
Rocket: TypePolicy(fields: {'launches': RelayStylePagination()}).