Basic
A list of posts and a detail opened from it, both reading from the same cache. A row whose post the cache already holds is marked cached, reopening that post shows it at once while a background fetch refreshes it, and a post nobody has looked at for ten seconds is dropped again. It is the shape of a product list with a product page, or a device list with a device screen: the second visit to a detail should never show a spinner, and entries nobody needs should not pile up.
What to try
- Watch the list arrive: the
postsdebug strip readsstatus=successandfetches=1, and no row carries a cached mark yet. - Open a post, then press the back arrow (Back to list). Its row is now bold and marked cached, and no other row is.
- Open the same post again. The title is on screen immediately, a
refreshing pill shows the background fetch, and the post's strip counts
fetches=2when it is done. - Go back and wait ten seconds without opening anything. The post's entry is garbage-collected and its cached mark disappears; the list itself stays.
The code
Two option functions: the list with the client's defaults, and one post
with a short gcTime, so an entry without readers is removed ten seconds
after its last one left.
QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
QueryObserverOptions<List<Post>>(
queryKey: ShowcaseKeys.posts,
queryFn: (context) => api.posts(signal: context.signal),
);
QueryObserverOptions<Post> postQuery(ShowcaseApi api, int id) =>
QueryObserverOptions<Post>(
queryKey: ShowcaseKeys.post(id),
queryFn: (context) => api.post(id, signal: context.signal),
gcTime: const GcTime.duration(postGcTime),
);
The list reads through a QueryBuilder. Whether a row is cached is not part
of the list's result, so the rows ask the client with getQueryData and
rebuild on the cache's own events through the showcase's CacheListener.
return QueryBuilder<List<Post>>(
options: postsQuery(api),
builder: (context, posts) => SectionCard(
title: 'Posts',
trailing: posts.isFetching && posts.dataOrNull != null
? const Pill('refreshing')
: null,
child: switch (posts) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
// Whether a row's post is cached is not part of this query's
// result: it is read straight from the cache, so the rows are
// rebuilt on the cache's own events.
CacheListener(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
for (final post in data)
_PostRow(
post: post,
cached: client
.getQueryData<Post>(ShowcaseKeys.post(post.id)) !=
null,
onOpen: () => onOpen(post.id),
),
],
),
),
},
),
);
The detail is its own widget reading context.query, so going back
unmounts the reader and the post's entry starts counting down its gcTime.
final post = context.query(postQuery(api, id));
return SectionCard(
title: 'Post #$id',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// A refetch over data already on screen: upstream's "Background
// Updating...". The first load shows the skeleton instead.
if (post.isFetching && post.dataOrNull != null)
const Pill('refreshing'),
IconButton(
tooltip: 'Back to list',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
],
),
child: switch (post) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(height: 20, width: 240),
SizedBox(height: 8),
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 (post case QueryError(:final error)) ...<Widget>[
Notice('Refetch failed: $error', error: true),
const SizedBox(height: 8),
],
Text(
data.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(data.body),
],
),
},
);
The whole screen
/// Upstream's `basic` example: a list of posts, a detail opened from it in
/// the same screen, and what the cache already knows shown in the list —
/// a row is marked `cached` when `getQueryData` finds its post. Reopening a
/// visited post shows it at once and refreshes it in the background; the
/// detail entry has a short `gcTime`, so a post left alone is dropped from
/// the cache ten seconds later and its mark disappears.
///
/// The list is a `QueryBuilder`, the detail reads `context.query` — two of
/// the four call styles, side by side.
///
/// Proofs (widget tests in `test/features/basic_test.dart`, end-to-end in
/// `e2e/tests/basic.spec.ts`): the list arrives after one request with no
/// row marked; opening a post fetches it once and marks its row on the way
/// back, no other row; reopening it shows the title from the cache while the
/// refetch is still in flight; the entry is collected once `gcTime` passes
/// and the mark goes with it; leaving the screen releases every observer.
library;
import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';
import '../../shared/api.dart';
import '../../shared/cache_listener.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 basicFeature = Feature(
id: 'basic',
title: 'Basic',
summary: 'A list, a detail, and what the cache already knows.',
upstream: 'basic',
);
/// How long a post's entry outlives its last reader. Upstream keeps posts
/// for a day; ten seconds is long enough to see the `cached` mark and short
/// enough to watch it go.
const Duration postGcTime = Duration(seconds: 10);
/// The list's query, with the client's defaults for everything else.
QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
QueryObserverOptions<List<Post>>(
queryKey: ShowcaseKeys.posts,
queryFn: (context) => api.posts(signal: context.signal),
);
/// One post's query. The default `staleTime` is what makes a reopened post
/// refetch in the background; [postGcTime] is what lets it go.
QueryObserverOptions<Post> postQuery(ShowcaseApi api, int id) =>
QueryObserverOptions<Post>(
queryKey: ShowcaseKeys.post(id),
queryFn: (context) => api.post(id, signal: context.signal),
gcTime: const GcTime.duration(postGcTime),
);
class BasicScreen extends StatefulWidget {
const BasicScreen({super.key});
State<BasicScreen> createState() => _BasicScreenState();
}
class _BasicScreenState extends State<BasicScreen> {
/// The open post, or null for the list — upstream's `postId` state.
int? _selectedId;
Widget build(BuildContext context) {
final selectedId = _selectedId;
return FeatureScaffold(
feature: basicFeature,
children: <Widget>[
if (selectedId == null) ...<Widget>[
// The strip goes above the list: thirty rows push anything below
// them out of the scaffold's lazily built viewport, and a strip
// that is not built is a strip no test can read.
QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'),
_PostList(onOpen: (id) => setState(() => _selectedId = id)),
] else ...<Widget>[
// Its own widget, so leaving it unmounts the `context.query`
// reader and releases the post's observer at once.
_PostDetail(
id: selectedId,
onBack: () => setState(() => _selectedId = null),
),
QueryDebugStrip(
queryKey: ShowcaseKeys.post(selectedId),
label: 'post-$selectedId',
),
QueryDebugStrip(queryKey: ShowcaseKeys.posts, label: 'posts'),
],
],
);
}
}
class _PostList extends StatelessWidget {
const _PostList({required this.onOpen});
final ValueChanged<int> onOpen;
Widget build(BuildContext context) {
final api = ShowcaseScope.apiOf(context);
final client = QueryClientProvider.of(context);
return QueryBuilder<List<Post>>(
options: postsQuery(api),
builder: (context, posts) => SectionCard(
title: 'Posts',
trailing: posts.isFetching && posts.dataOrNull != null
? const Pill('refreshing')
: null,
child: switch (posts) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
SizedBox(height: 8),
SkeletonBox(),
],
),
QueryError(:final error, staleData: null) =>
Notice('$error', error: true),
QuerySuccess(:final data) ||
QueryError(staleData: final data!) =>
// Whether a row's post is cached is not part of this query's
// result: it is read straight from the cache, so the rows are
// rebuilt on the cache's own events.
CacheListener(
builder: (context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
for (final post in data)
_PostRow(
post: post,
cached: client
.getQueryData<Post>(ShowcaseKeys.post(post.id)) !=
null,
onOpen: () => onOpen(post.id),
),
],
),
),
},
),
);
}
}
class _PostRow extends StatelessWidget {
const _PostRow({
required this.post,
required this.cached,
required this.onOpen,
});
final Post post;
final bool cached;
final VoidCallback onOpen;
Widget build(BuildContext context) => SemanticsGroup(
// A named group per row, so a test can ask for row 3's mark and
// nobody else's; the mark stays a text of its own outside the button.
name: 'post ${post.id}',
child: Row(
children: <Widget>[
Expanded(
child: MergeSemantics(
child: Semantics(
button: true,
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 10),
child: Text(
post.title,
style: cached
? TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
)
: null,
),
),
),
),
),
),
if (cached) const Pill('cached'),
],
),
);
}
class _PostDetail extends StatelessWidget {
const _PostDetail({required this.id, required this.onBack});
final int id;
final VoidCallback onBack;
Widget build(BuildContext context) {
final api = ShowcaseScope.apiOf(context);
final post = context.query(postQuery(api, id));
return SectionCard(
title: 'Post #$id',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// A refetch over data already on screen: upstream's "Background
// Updating...". The first load shows the skeleton instead.
if (post.isFetching && post.dataOrNull != null)
const Pill('refreshing'),
IconButton(
tooltip: 'Back to list',
onPressed: onBack,
icon: const Icon(Icons.arrow_back),
),
],
),
child: switch (post) {
QueryPending() => const Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
SkeletonBox(height: 20, width: 240),
SizedBox(height: 8),
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 (post case QueryError(:final error)) ...<Widget>[
Notice('Refetch failed: $error', error: true),
const SizedBox(height: 8),
],
Text(
data.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(data.body),
],
),
},
);
}
}
Related
- Guides: Quick start, Queries, Caching
- Upstream: TanStack's React
basicexample - Tested by
test/features/basic_test.dart(widget) ande2e/tests/basic.spec.ts(browser) - View the feature on GitHub