List to detail, seeded
The list has just loaded twenty products, each with its name and price. The user taps one, and the detail screen shows a spinner while it asks the server for a product the app received a second ago. It should open with that product instead, fetch nothing if the list is fresh, and fetch in the background if the list is old — without the detail ever being treated as newer than the data it came from. Two techniques do it, and this recipe uses both: the list pushes each product into its detail entry when it loads, and the detail pulls from the lists when it opens on an entry nobody pushed.
The finished code
The list query, which writes every product it receives into that product's detail entry:
QueryObserverOptions<List<Product>> productListQuery(
ProductApi api, {
String search = '',
}) =>
QueryObserverOptions<List<Product>>(
queryKey: ProductKeys.list(search: search),
queryFn: (context) async {
final products = await api.list(search: search, signal: context.signal);
// One entry per product, so the detail screen opens with data and
// every later list response reaches the detail too.
for (final product in products) {
context.client
.setQueryData<Product>(ProductKeys.detail(product.id), product);
}
return products;
},
staleTime: const StaleTime.duration(Duration(seconds: 30)),
);
The detail query, with a fallback that looks through the lists already in the cache:
QueryObserverOptions<Product> productQuery(
QueryClient client,
ProductApi api,
String id,
) =>
QueryObserverOptions<Product>(
queryKey: ProductKeys.detail(id),
queryFn: (context) => api.get(id, signal: context.signal),
staleTime: const StaleTime.duration(Duration(seconds: 30)),
// The fallback for an entry the list has not seeded yet — a deep
// link, or a list still loading when the row was tapped.
initialData: InitialData<Product>.compute(() => _findInLists(client, id)),
// As old as the list it came from, so it is refetched when that is
// stale rather than trusted as brand new.
initialDataUpdatedAtCompute: () => _listUpdatedAt(client, id),
);
Product? _findInLists(QueryClient client, String id) {
for (final (_, products) in client.getQueriesData<List<Product>>(
filters: QueryFilters(queryKey: ProductKeys.lists),
)) {
for (final product in products ?? const <Product>[]) {
if (product.id == id) return product;
}
}
return null;
}
DateTime? _listUpdatedAt(QueryClient client, String id) {
for (final (key, products) in client.getQueriesData<List<Product>>(
filters: QueryFilters(queryKey: ProductKeys.lists),
)) {
if (products?.any((product) => product.id == id) ?? false) {
return client.getQueryState<List<Product>>(key)?.dataUpdatedAt;
}
}
return null;
}
A row of the list, which opens the detail:
class ProductTile extends StatelessWidget {
const ProductTile(this.product, {super.key});
final Product product;
Widget build(BuildContext context) => ListTile(
title: Text(product.name),
subtitle: Text(formatPrice(product.price)),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ProductDetailScreen(id: product.id),
),
),
);
}
And the detail screen, read through a QueryBuilder:
class ProductDetailScreen extends StatelessWidget {
const ProductDetailScreen({super.key, required this.id});
final String id;
Widget build(BuildContext context) {
final client = QueryClientProvider.of(context);
final api = ProductApiScope.of(context);
return QueryBuilder<Product>(
options: productQuery(client, api, id),
builder: (context, product) => Scaffold(
appBar: AppBar(title: Text(product.dataOrNull?.name ?? 'Product')),
body: switch (product) {
QueryPending() => const Center(child: CircularProgressIndicator()),
QueryError(:final error, staleData: null) =>
Center(child: Text('Could not load: $error')),
QueryError(staleData: final data?) ||
QuerySuccess(:final data) =>
ProductDetails(data, refreshing: product.isFetching),
},
),
);
}
}
ProductKeys and ProductApi are from
Wiring dio or package:http.
How it works
- The list pushes.
context.clientis the client running the fetch. After the list arrives,setQueryDatawrites each product underProductKeys.detail(id), stamped with the time of the write. A detail opened in the next thirty seconds is fresh: it shows at once and fetches nothing. - Every later list response reaches the details too. A refetch of the list writes the products again, so a detail entry never shows a price older than the list next to it.
- The detail pulls when nothing was pushed.
InitialData.computeruns only when the detail's entry does not exist yet — a deep link, a detail entry that was garbage-collected while the list was kept, or a list that is still loading from another screen. It looks through every cached list withgetQueriesDataand returns the product if one of them has it. - Pulled data is as old as its list.
initialDataUpdatedAtComputereturns the list'sdataUpdatedAt. Initial data without it would be stamped now, and a product from a list loaded ten minutes ago would count as fresh. With it, the detail is stale when its list is, and refetches in the background while showing the seeded product. nullmeans "not found". When no list has the product,computereturnsnulland the query startspending, like any first load. The screen's spinner is still there for that case.- The screen shows what it has.
QueryErrorwithstaleDatashows the product it already had;product.isFetchingputs a thin progress bar over a background refetch.
Try it in the demo's card A: open a post — it appears at once, seeded from the cached list, and the debug strip under it shows no fetch. Switch on "Treat initial data as old" and open another: the seed still shows at once, and a background fetch follows, because the seed is dated as old as its list.
Traps
- Seed from the list's type, not the widget's.
getQueriesData<List<Product>>checks the type of every entry it matches and throws aQueryDataTypeErrorfor one that holds something else. That is why the endless feed's key (ProductKeys.feed, which holds pages) sits besideProductKeys.listsrather than under it: under the prefix, the lookup would reach it and throw. initialDatais only for an entry that does not exist. When the detail entry is already in the cache — pushed by the list, or loaded before —computenever runs. It does not overwrite; it fills an empty slot.- Pushing costs entries. A list of 500 products writes 500 detail entries.
They are cheap, and garbage-collected after
gcTimewhen nothing reads them, but a list with large pages is a reason to pull only. - A list row is not always a whole detail. If the list endpoint sends a summary (name and price) and the detail has more (a description), seeding the detail with a summary shows a screen with holes. Use placeholder data from the list instead: it shows while the full detail loads, and is never cached as the detail.
- Keep the pushed product equal.
Producthas value equality, so a refetch that returns the same product changes nothing and rebuilds nothing.
Variations
- Pull only. Drop the loop from the list's query function. Every detail is seeded when it opens, from whichever list has it; nothing is written up front.
- Push only. Drop
initialDatafrom the detail. A deep link then loads with a spinner, which is often fine. - Another call style.
context.query(productQuery(...)), the mixin'swatchQueryor aQueryControllerread the same options; nothing on this page depends on theQueryBuilder.
The same two approaches, often called push and pull: queryClient.setQueryData
in the list's queryFn, or initialData with initialDataUpdatedAt from
getQueryState(...).dataUpdatedAt. InitialData.compute is the function form
of initialData.
See also
- Initial query data
—
initialData, its timestamp, and the other seeding patterns. - Updates from mutation responses — the same write, after a save.
- Caching —
staleTime,gcTime, and when an entry is fetched.