Skip to main content

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.

Live demoBasicA list, a detail, and what the cache already knows.~3 MB, runs in your browser; no server involved.

What to try​

  • Watch the list arrive: the posts debug strip reads status=success and fetches=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=2 when 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.

examples/showcase/lib/features/basic/basic_screen.dart · lines 45–49
QueryObserverOptions<List<Post>> postsQuery(ShowcaseApi api) =>
QueryObserverOptions<List<Post>>(
queryKey: ShowcaseKeys.posts,
queryFn: (context) => api.posts(signal: context.signal),
);
examples/showcase/lib/features/basic/basic_screen.dart · lines 53–58
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.

examples/showcase/lib/features/basic/basic_screen.dart · lines 110–152
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.

examples/showcase/lib/features/basic/basic_screen.dart · lines 213–262
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
examples/showcase/lib/features/basic/basic_screen.dart
/// 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),
],
),
},
);
}
}