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>:
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'), ), ]), );}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 for every cursor in the controller during a single build.PaginatedStatehands the builderitems(all pages flattened),hasMore,totalCount,loadMore(),refetch(), plusisLoading/hasMissingData/errorfrom the underlyingQueryBuilder.PaginationControlleris optional. Pass your own when something outside the builder mustreset()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 |
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 }}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:
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'), ), ]); }, );}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];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.
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>[];What is missing
Section titled “What is missing”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.