Skip to main content

Query invalidation

Waiting for data to go stale by itself is fine for data that changes elsewhere. It is not good enough when your app just changed it: the user renamed a task, and the list on the previous screen still shows the old name for as long as its staleTime says it is fresh. You know the cached data is wrong. invalidateQueries is how you say so.

// Every key that starts with ['tasks']: the lists and every detail.
await client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.all),
);

// This one detail only.
await client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.detail(id), exact: true),
);

// Mark everything under ['tasks'] stale, and refetch nothing now.
await client.invalidateQueries(
filters: QueryFilters(queryKey: TaskKeys.all),
refetchType: RefetchType.none,
);

An invalidation does two things to every query it matches:

  1. It marks the query stale, whatever its staleTime says — except StaleTime.static, which an invalidation leaves fresh. So the next mount, focus or reconnect refetches it.
  2. It refetches the query now if it is active — if a widget or an observer is reading it. A query nobody reads waits until something reads it again, and then refetches because it is stale.

The future completes when those refetches have settled. A refetch that fails does not fail it — the error lands in the query's state, where its readers see it — and a refetch paused because the device is offline is not waited for.

Keys decide what an invalidation reaches​

A filter's queryKey matches as a prefix: ['tasks'] matches ['tasks'], ['tasks', 'list', 'open'] and ['tasks', 'detail', '7']. That is why keys are built from the most general part down, and why an app usually keeps them in one place. In a smart-home app, a key factory makes every level of the device hierarchy something you can name:

// lib/data/device_keys.dart
abstract final class DeviceKeys {
static final QueryKey all = QueryKey(<Object?>['devices']);

static final QueryKey lists = all.append(<Object?>['list']);

static QueryKey list({required String room}) => lists.append(<Object?>[room]);

static final QueryKey details = all.append(<Object?>['detail']);

static QueryKey detail(String id) => details.append(<Object?>[id]);
}

With that, every invalidation in the app is a sentence about the domain rather than a list of string arrays:

// Something changed about this device: its detail, and every room list.
await client.invalidateQueries(
filters: QueryFilters(queryKey: DeviceKeys.detail(device.id)),
);
await client.invalidateQueries(
filters: QueryFilters(queryKey: DeviceKeys.lists),
);

// A bulk import from the hub: everything about devices is suspect.
await client.invalidateQueries(
filters: QueryFilters(queryKey: DeviceKeys.all),
);

DeviceKeys.lists matches every room's list; DeviceKeys.all matches the lists, every detail and anything filed under a detail. See query keys for how keys compare, maps inside keys included.

Narrowing the match​

exact: true matches the key itself and nothing below it — QueryFilters(queryKey: DeviceKeys.lists, exact: true) matches a query whose key is exactly ['devices', 'list'], and no room's list.

predicate takes the Query and says yes or no, for anything a key prefix cannot express. It runs after the other fields have matched, so give it a prefix to keep the set it is asked about small:

// The kitchen and the hallway were merged on the hub: invalidate the lists
// of both rooms, and nothing else.
const merged = <String>{'kitchen', 'hallway'};
await client.invalidateQueries(
filters: QueryFilters(
queryKey: DeviceKeys.lists,
predicate: (query) => merged.contains(query.queryKey.parts.last),
),
);

The other filters work too: type, stale, status and fetchStatus.

Which queries refetch​

refetchType decides the second step:

RefetchTypeRefetches
activematching queries a reader is observing — the default, unless the filters' own type says otherwise
inactivematching queries nobody is observing
allboth
nonenothing — the queries are only marked stale

RefetchType.all is for data you know will be needed again soon, such as a list behind the screen the user will return to. RefetchType.none is for a change that should show up only when the data is next read.

A disabled query is marked but never refetched by an invalidation, and neither is an observed query whose staleTime is StaleTime.static.

A fetch already in flight​

cancelRefetch (default true) decides what happens to a matching query that is already fetching. If the query holds data, its fetch is cancelled and a new one started, so what lands was fetched after your call — the fetch that was running may have left before the server saw your write. If it has no data yet, the running fetch is joined instead: that first load is the answer anyway. Pass false to join a running fetch in every case.

Awaiting it, or not​

await the invalidation when the caller has to know the screen is consistent — a pull-to-refresh whose indicator should stay until the new data is there. Otherwise call .ignore() on the future and let the refetch run behind whatever comes next. After a mutation the choice decides how long the mutation stays pending; see invalidations from mutations.

The invalidation-and-filters screen runs every one of these calls against a small cache: the posts list, two observed posts, one post nobody reads, and the todos. Press Invalidate posts prefix and watch the three observed posts entries refetch while post 3 is only marked stale; Invalidate posts exactly refetches the list alone, and Invalidate inactive too fetches post 3 as well.

Live demoInvalidation and filtersInvalidate, refetch, reset and remove, by prefix, type or predicate.~3 MB, runs in your browser; no server involved.
CallWhat it does
refetchQueriesrefetches matching queries now, without marking anything stale; skips disabled and static ones
resetQueriesputs matching queries back to their initial state (their initialData, or nothing), then refetches the active ones
removeQueriesdrops matching queries from the cache — for keys nobody is watching; a reader still attached keeps showing what it had
cancelQueriescancels matching fetches in flight and, by default, puts each query back to the state it had before the fetch
In React Query

The same queryClient.invalidateQueries, with the same prefix matching, exact, predicate, refetchType and cancelRefetch. The filters are a named filters: argument here rather than the first argument, and StaleTime.static is TanStack Query's staleTime: 'static'.