Skip to content

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:

type_policies.dart (example app)
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, last and before are not part of the cache key; every other argument is (filter, orderBy). launches(first: 20) and launches(first: 20, after: "…") are stored together at ROOT_QUERY.launches; launches(first: 20, filter: {status: SUCCESS}) is another merged list. Pass RelayStylePagination(keyArgs: ['filter']) to choose them yourself.
  • Pages append. A page after a cursor is appended to nodes / edges (prepended for before); pageInfo.endCursor / hasNextPage come from the last page, startCursor / hasPreviousPage from the first, totalCount from 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 stale maxAge — 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.

The example’s list body is a PaginatedQueryBuilder<Query, Launch>:

launches_screen.dart (excerpt)
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'),
),
]),
);
}
  • page maps the query root and a cursor to a ConnectionPage — the generated connection types are plain classes, so you name the four fields once. The widget calls it with null (the first page, i.e. the merged list) and, while a page is loading, with that page’s cursor.
  • PaginatedState hands the builder items (the merged list, then the skeleton of the page loading), hasMore, totalCount, loadMore(), refetch(), plus isLoading / hasMissingData / isSkeleton / isStale / error and revalidate() from the underlying QueryBuilder.
  • QueryBuilder’s options — fetchPolicy, maxAge, errorPolicy, timeout, debugLabel, scheduler — are PaginatedQueryBuilder’s too, passed to it as is.
  • PaginationController holds the cursor of the page being loaded. It is optional: pass your own when something outside the builder must reset() 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
request after one "Load more"
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
}
}

Pages of five:

example/lib/demos/pagination_demo.dart
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.

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'),
),
]);
},
);
}

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.

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()}).