Queries
A screen that shows server data has to answer the same questions every time:
is it loading, did it fail, is what I show still current, is a refresh
running? Written by hand, that is a Future in a State, three flags and a
setState per screen — and two screens showing the same data fetch it twice.
A query answers those questions once. It is a declarative dependency on a
piece of asynchronous data, tied to a unique key. You give it the key and
a function that returns a Future; the cache decides when to call the
function, keeps the result under the key, and tells every widget that reads
it.
Describing a query
The smallest useful shape is a function that returns the options, kept next to the rest of the app's data layer:
// lib/data/device_queries.dart
QueryObserverOptions<List<Device>> devicesQuery({String? roomId}) =>
QueryObserverOptions(
queryKey: DeviceKeys.list(roomId: roomId),
queryFn: (context) =>
repository.devices(roomId: roomId, signal: context.signal),
);
QueryObserverOptions<Device> deviceQuery(String id) => QueryObserverOptions(
queryKey: DeviceKeys.detail(id),
queryFn: (context) => repository.device(id, signal: context.signal),
);
- The key is how the cache stores, shares and invalidates the data. Two widgets that build the same key share one entry and one request. See query keys.
- The function resolves with the data or throws. Passing
context.signalon lets the cache cancel a request nobody waits for any more. See query functions. - The type comes from the function:
devicesQuery()is aQueryObserverOptions<List<Device>>, and so is everything read through it.
A function per query, not a widget-local literal, is what lets the same description be read on one screen, prefetched on another and invalidated by a mutation — see describing a query once.
Reading it on a screen
Every call style hands you the same QueryResult. Here it is read with
context.query, one of four equal ways:
// lib/ui/devices_screen.dart
class DevicesScreen extends StatelessWidget {
const DevicesScreen({super.key});
Widget build(BuildContext context) {
final devices = context.query(devicesQuery());
return Scaffold(
appBar: AppBar(
title: const Text('Devices'),
// A background refetch: the list stays, and a thin bar says so.
bottom: devices.isRefetching
? const PreferredSize(
preferredSize: Size.fromHeight(2),
child: LinearProgressIndicator(minHeight: 2),
)
: null,
),
body: switch (devices) {
QueryPending() => const Center(child: CircularProgressIndicator()),
// A refetch failed: say so above the data that is still good.
QueryError(:final error, :final staleData?) => Column(
children: <Widget>[
Text('Could not refresh: $error'),
Expanded(child: DeviceListView(staleData)),
],
),
// The first load failed: there is nothing to show.
QueryError(:final error) =>
Center(child: Text('Could not load devices: $error')),
QuerySuccess(:final data) => RefreshIndicator(
onRefresh: devices.refetch,
child: DeviceListView(data),
),
},
);
}
}
Four things are happening without code of their own:
- The first build starts the fetch; the second screen that reads
devicesQuery()does not start another. - Pull-to-refresh awaits
refetch(), which completes when the fetch does. - A failed refetch keeps the list:
staleDatais the last good value, and the banner sits above it. - Leaving the screen and coming back within five minutes renders the cached list at once and refetches behind it — the important defaults at work.
The showcase's basic screen is that last point, live. Tap a post, go back —
its row now says cached — and open it again: the title is there at once,
with a refreshing pill while the refetch runs. The screen sets a gcTime of
ten seconds, so wait that long on the list and the entry is gone:
The three states
A QueryResult<T> is sealed, and is exactly one of:
QueryPending— there is no data yet.QuerySuccess—dataholds the data.QueryError— the query failed.errorandstackTracesay why, andstaleDataholds the last good data when there was any (hasStaleDatatells a realnullfrom none).
result.status gives the same thing as a QueryStatus value, and
isPending, isSuccess and isError are there for when a switch is more
than you need. result.dataOrNull is the data of a success or the stale data
of an error.
What the query is doing: fetchStatus
The status says whether there is data. It says nothing about whether a fetch
is running — a query can hold data and be refetching it. That is the second
axis, fetchStatus:
FetchStatus.fetching— the function is running (a first load, a retry, or a background refetch).FetchStatus.paused— it wanted to fetch but may not go on yet. A fetch under the default network mode that finds the client offline waits before its first attempt. A fetch that failed and is due for a retry waits between attempts while the client is offline, and also while the app is in the background — a retry resumes when the app is back in the foreground. See network mode.FetchStatus.idle— nothing is running.
Any status combines with any fetch status. The flags on every result name the common combinations:
| Flag | Means |
|---|---|
isFetching | fetchStatus is fetching — anything is running |
isPaused | fetchStatus is paused |
isLoading | pending and fetching: the first load is in flight |
isRefetching | fetching but not pending: a background refetch of data already there |
isLoadingError (on QueryError) | failed with no data to show |
isRefetchError (on QueryError) | a refetch failed; staleData is still good |
A switch can use them directly:
Widget taskTitle(QueryResult<Task> task) => switch (task) {
QueryPending(isPaused: true) => const Text('Waiting for the network…'),
QueryPending() => const Text('Loading…'),
QueryError(:final staleData?) =>
Text('${staleData.name} (not refreshed)'),
QueryError(:final error) => Text('Could not load: $error'),
QuerySuccess(:final data, isRefetching: true) => Text('${data.name} …'),
QuerySuccess(:final data) => Text(data.name),
};
Other fields worth knowing
dataUpdatedAtanderrorUpdatedAt— when the data or the error arrived.isStale— whether the data is older than itsstaleTime, or was invalidated, andtruewhile there is no data yet. A disabled query is never stale, and neither is aStaleTime.staticquery that has data — invalidation included.failureCountandfailureReason— how many attempts of the current fetch have failed, while it retries. See retries.consecutiveErrorCount— fetches in a row that ended in an error. See polling.isPlaceholderData— the data is a placeholder, not in the cache. See placeholder data.refetch()— fetch again now. Its future completes with the new result. A fetch already running over data is cancelled and replaced; passcancelRefetch: falseto join it instead. A first load, with no data yet, is always joined.
Traps
- Build the options, not the client, in
build. An options function is cheap to call on every build: the reader compares it by value and changes nothing when it is equal. AQueryClientbuilt inbuildis a new, empty cache every time. isLoadingis not "a spinner is needed". It is false during a background refetch, when the data is on screen — which is what you want — and also false for a pending query that is paused. MatchQueryPending(isPaused: true)if that deserves its own message.- An error does not clear the data. Match
QueryError(:final staleData?)beforeQueryError(), or the screen blanks on the first failed refresh.
This is useQuery. status and fetchStatus are the same two axes; the
sealed result replaces data | undefined with a field that exists only on the
states that have it, and isLoadingError/isRefetchError move onto
QueryError. See differences from
TanStack Query.