Skip to content

Pagination

sling_gql needs no pagination machinery: a field called with different arguments is a different cache entry, and a widget’s selection can include as many of them as it likes. Pagination is then just state — which pages to read. PaginatedQueryBuilder owns that state for cursor connections; the manual recipe below shows what it does.

Cursor pagination with PaginatedQueryBuilder

Section titled “Cursor pagination with PaginatedQueryBuilder”

The mock API exposes launches(first, after): LaunchConnection!. 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(); // cursors: [null] = first page
void _onSegmentChanged(String? status) {
setState(() => _status = status);
_pagination.reset(); // new filter → back to page one
}
@override
Widget build(BuildContext context) => PaginatedQueryBuilder<Query, Launch>(
controller: _pagination,
// Runs once per loaded page, 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 for every cursor in the controller during a single build.
  • PaginatedState hands the builder items (all pages flattened), hasMore, totalCount, loadMore(), refetch(), plus isLoading / hasMissingData / error from the underlying QueryBuilder.
  • PaginationController 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.

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 new page; page 1 is served from cache
state.refetch() (pull to refresh) launches_a and launches_b in one document — refetch() replays the whole last selection, cursors are kept
controller.reset() back to launches_a, from cache; the other pages stay cached too
request after one "Load more"
query ($first: Int, $after: String) {
launches_fg1t1s: launches(first: $first, after: $after) {
__typename
nodes { __typename status name date id rocket { __typename name } }
pageInfo { __typename hasNextPage endCursor }
totalCount
}
}

PaginatedQueryBuilder is thin — this is what it does, and what you would write for a connection shape it does not fit:

class _LaunchesScreenState extends State<LaunchesScreen> {
static const pageSize = 20;
final List<String?> _cursors = [null]; // null = first page
@override
Widget build(BuildContext context) => QueryBuilder<Query>(
builder: (context, query, state) {
final pages = [
for (final cursor in _cursors) query.launches(first: pageSize, after: cursor),
];
final launches = [for (final page in pages) ...?page?.nodes];
final last = pages.last;
final hasMore = last?.pageInfo?.hasNextPage ?? false;
final endCursor = last?.pageInfo?.endCursor; // read during build, see below
final total = last?.totalCount;
return CustomScrollView(slivers: [
CupertinoSliverRefreshControl(onRefresh: state.refetch),
SliverList.builder(
itemCount: launches.length,
itemBuilder: (_, i) => _LaunchRow(launches[i]),
),
SliverToBoxAdapter(
child: hasMore
? CupertinoButton.filled(
onPressed: () => setState(() => _cursors.add(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];

Changing _filter or _order changes the arguments, hence the alias, hence the cache entry — the old pages stay cached (useful if the user toggles back), the new ones are fetched.

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>[];

Each page’s nodes is a list of references to Launch:<id> entities, so a launch that shows up in two pages is stored once and a future favourite mutation updates every row. What is still missing is a connection merge policy: the pages are separate cache entries stitched together by the widget, not one growing list in the cache (Apollo’s relayStylePagination). See Caching.