Pagination
A list of projects, ten to a page, with Previous page and Next page
buttons. The page number is part of the query key, so every page is its own
cache entry. PlaceholderData.keepPrevious() keeps the last page's rows on
screen while a new page loads instead of dropping back to a skeleton, and
as soon as a page has real data the next one is prefetched, so the usual
press of Next page costs no request. This is the shape of any paged
table backed by a ?page= API: an order history, an admin list of users,
search results with numbered pages.
What to try
- Watch the first load:
page-0shows one fetch, and then thepage-1debug strip readsstatus=successwithobservers=0. The next page was prefetched with nobody reading it. - Press Next page within five seconds of the load. Page 1's rows appear
at once with
isPlaceholderData=falseand no new request (after that the prefetched page is stale and refetches in the background as it is shown), and the strip for page 2 shows its prefetch. - Press Next page twice in quick succession. For the moment the next
page's prefetch is still running, the previous page's rows stay on screen
with
isPlaceholderData=true, a loading pill, and Next page disabled, so nobody skips past a page they have not seen. - Press Previous page within five seconds: the cached page comes back with no request. Wait longer and the cached rows still show at once, with a background refetch because the page has gone stale.
- Page through to
page=9.hasMore=false, Next page is disabled, and thepage-10strip readsstatus=absent: nothing beyond the last page is prefetched.
The code
The read and the prefetch share a key and a staleTime, so a prefetched
page counts as fresh when it is opened. The read's placeholder is the
const keepPrevious(), which compares equal on every rebuild.
QueryObserverOptions<ProjectPage> projectsPageQuery(
ShowcaseApi api,
int page,
) =>
QueryObserverOptions<ProjectPage>(
queryKey: projectsPageKey(page),
queryFn: (context) => api.projectsPage(page, signal: context.signal),
staleTime: projectsPageStaleTime,
placeholderData: const PlaceholderData<ProjectPage>.keepPrevious(),
);
QueryOptions<ProjectPage> projectsPagePrefetch(ShowcaseApi api, int page) =>
QueryOptions<ProjectPage>(
queryKey: projectsPageKey(page),
queryFn: (context) => api.projectsPage(page, signal: context.signal),
staleTime: projectsPageStaleTime,
);
The screen reads the current page with context.query under an id, so
one observer follows the key from page to page and has the previous page's
data to keep. Once the page has real data and there is more, it prefetches
the next one, after the frame.
// The `id` is what lets the observer follow the key: without it a new
// page would be a new observer, with no previous data to keep.
final result = context.query(projectsPageQuery(api, page), id: 'page');
final data = result.dataOrNull;
final hasMore = data?.hasMore ?? false;
if (data != null &&
!result.isPlaceholderData &&
hasMore &&
_prefetchedFrom != page) {
_prefetchNext(page);
}
final canGoBack = page > 0;
// Upstream's rule: no skipping past a page that is still a placeholder.
final canGoForward = !result.isPlaceholderData && hasMore;
The whole screen
/// Upstream's `pagination` example: page-numbered projects, one cache entry
/// per page, read with `context.query` under an `id:` so the observer follows
/// the key from page to page. `const PlaceholderData.keepPrevious()` —
/// upstream's `keepPreviousData` — keeps the last page's rows on screen while
/// the next loads, and `isPlaceholderData` says when that is what is showing.
/// It does what `.compute((previous, _) => previous)` does, a previous `null`
/// meaning "no placeholder" included, and being `const` it is canonicalised:
/// every build of this screen passes the identical value, so the options
/// compare equal and the observer's placeholder memoisation holds. A fresh
/// inline closure never compares equal to the last one, and cannot.
///
/// `Next page` is disabled while a placeholder is on screen,
/// as upstream disables it, so nobody skips past a page they have not seen;
/// and while the current page has real data and `hasMore`, the next page is
/// prefetched with `client.query(...).ignore()`, so the usual `Next page`
/// costs no request at all. `staleTime` is 5 s, upstream's, which is what
/// makes the prefetched page count as fresh when it is opened — within those
/// 5 s. A page opened or returned to after that is shown from the cache at
/// once and refetched in the background.
///
/// Proofs (widget tests in `test/features/pagination_test.dart`, end-to-end
/// in `e2e/tests/pagination.spec.ts`): page 0 costs one request and, once it
/// has data, page 1 is fetched with nobody observing it; `Next page` onto the
/// prefetched page costs no request and prefetches the page after; a page
/// whose prefetch has not answered shows the previous page's rows with
/// `isPlaceholderData=true` and `Next page` disabled until it does; `Previous
/// page` returns to a cached, still fresh page with no request; on the last page `Next
/// page` is disabled and nothing beyond it is prefetched.
library;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';
import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';
const Feature paginationFeature = Feature(
id: 'pagination',
title: 'Pagination',
summary:
'Page by page, keeping the previous page on screen while the next loads.',
upstream: 'pagination',
);
/// One entry per page: the page number is part of the key.
QueryKey projectsPageKey(int page) =>
QueryKey(<Object?>['projects', 'page', page]);
/// Upstream's 5 s: the window within which the prefetched next page counts as
/// fresh, so opening it costs no request.
const StaleTime projectsPageStaleTime =
StaleTime.duration(Duration(seconds: 5));
/// The screen's read. The placeholder is the previous page's data, whatever
/// it was, so the rows never blank out between pages — and it is the `const`
/// variant, so a rebuild hands the observer a value equal to the last one.
QueryObserverOptions<ProjectPage> projectsPageQuery(
ShowcaseApi api,
int page,
) =>
QueryObserverOptions<ProjectPage>(
queryKey: projectsPageKey(page),
queryFn: (context) => api.projectsPage(page, signal: context.signal),
staleTime: projectsPageStaleTime,
placeholderData: const PlaceholderData<ProjectPage>.keepPrevious(),
);
/// The prefetch's options: the cache-layer kind, since nothing observes it.
/// Same key and `staleTime` as [projectsPageQuery], so the read finds the
/// entry fresh.
QueryOptions<ProjectPage> projectsPagePrefetch(ShowcaseApi api, int page) =>
QueryOptions<ProjectPage>(
queryKey: projectsPageKey(page),
queryFn: (context) => api.projectsPage(page, signal: context.signal),
staleTime: projectsPageStaleTime,
);
class PaginationScreen extends StatefulWidget {
const PaginationScreen({super.key});
State<PaginationScreen> createState() => _PaginationScreenState();
}
class _PaginationScreenState extends State<PaginationScreen> {
int _page = 0;
/// The page whose successor has been prefetched. Upstream prefetches from
/// an effect on `[data, page]`; here the build is the effect, and this is
/// what keeps one page from prefetching on every rebuild.
int? _prefetchedFrom;
void _prefetchNext(int page) {
_prefetchedFrom = page;
final api = ShowcaseScope.apiOf(context);
final client = QueryClientProvider.of(context);
// After the frame, not inside the build: starting a fetch fires cache
// events, and the widgets listening to them are mid-build right now.
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
client.query(projectsPagePrefetch(api, page + 1)).ignore();
}
});
}
Widget build(BuildContext context) {
final api = ShowcaseScope.apiOf(context);
final page = _page;
// The `id` is what lets the observer follow the key: without it a new
// page would be a new observer, with no previous data to keep.
final result = context.query(projectsPageQuery(api, page), id: 'page');
final data = result.dataOrNull;
final hasMore = data?.hasMore ?? false;
if (data != null &&
!result.isPlaceholderData &&
hasMore &&
_prefetchedFrom != page) {
_prefetchNext(page);
}
final canGoBack = page > 0;
// Upstream's rule: no skipping past a page that is still a placeholder.
final canGoForward = !result.isPlaceholderData && hasMore;
return FeatureScaffold(
feature: paginationFeature,
children: <Widget>[
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Notice(
'Each page keeps the previous one on screen while it loads, and '
'the next page is prefetched as soon as this one has data. Pages '
'stay fresh for 5 s: going back within that costs no request; '
'later, the cached page shows at once and refetches in the '
'background.',
),
),
SectionCard(
title: 'Pages',
trailing: result.isFetching ? const Pill('loading') : null,
child: SemanticsGroup(
// Explicit children keep each button and each `key=value` text a
// node of its own rather than folded into the row.
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 12,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
OutlinedButton(
onPressed:
canGoBack ? () => setState(() => _page -= 1) : null,
child: const Text('Previous page'),
),
FilledButton(
onPressed: canGoForward
? () => setState(() => _page += 1)
: null,
child: const Text('Next page'),
),
Text('page=$page'),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 2,
children: <Widget>[
Text('isPlaceholderData=${result.isPlaceholderData}'),
Text('hasMore=$hasMore'),
Text('isFetching=${result.isFetching}'),
],
),
],
),
),
),
QueryDebugStrip(queryKey: projectsPageKey(page), label: 'page-$page'),
QueryDebugStrip(
queryKey: projectsPageKey(page + 1),
label: 'page-${page + 1}',
),
SectionCard(
title: 'Projects',
child: switch (result) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(),
SizedBox(height: 4),
SkeletonBox(),
SizedBox(height: 4),
SkeletonBox(),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (result case QueryError(:final error)) ...<Widget>[
Notice('Fetch failed: $error', error: true),
const SizedBox(height: 8),
],
for (final project in data.projects)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text('Project ${project.id}'),
),
],
),
},
),
],
);
}
}
Related
- Guides: Paginated queries, Placeholder query data, Prefetching
- Upstream: TanStack's React
paginationexample - Tested by
test/features/pagination_test.dart(widget) ande2e/tests/pagination.spec.ts(browser) - View the feature on GitHub