Invalidating Queries With a Key Factory
A key factory earns most of its value at invalidation time. Because every key is a nested tuple extending a shared root, you can flush an entire branch of the cache with one prefix, target a single query with exact: true, or express an arbitrary target with a predicate — all without hand-writing a key that might drift from the query it means to hit. This page is the invalidation half of Query Key Factory Patterns; it assumes you already have the factory from Building a Type-Safe Query Key Factory and focuses on how TanStack Query’s matching semantics turn that hierarchy into a precise invalidation API.
The mental model to hold: invalidateQueries, removeQueries, and setQueriesData all take a filters object, and by default queryKey inside that object is matched as a prefix, not an exact key.
Diagnostic Checklist
Reach for the techniques below when you see:
- A mutation calls
invalidateQueriesand refetches far more than intended, hammering the network after every write. - A write refreshes list views but leaves the detail view the user is reading stale, or vice versa.
- You need to invalidate “all done lists” or “everything for user X” and a single prefix cannot express it.
- After a mutation you want the change reflected instantly with no refetch, but you are calling
invalidateQueriesand watching a spinner.
Step-by-Step Implementation
Step 1 — Invalidate a whole branch with a prefix
The default behavior of invalidateQueries is fuzzy prefix matching. Pass a grouping accessor and every key that starts with that prefix is marked stale.
import { useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './todo-keys';
export function useInvalidators() {
const queryClient = useQueryClient();
return {
// Every list variant, regardless of filters
allLists: () => queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
// The entire todos domain — lists and details
everything: () => queryClient.invalidateQueries({ queryKey: todoKeys.all }),
};
}
Cache Behavior Analysis. TanStack Query compares the supplied queryKey against each cached query’s key element by element, left to right; a query matches when the filter key is a prefix of it. todoKeys.lists() → ['todos','list'] matches ['todos','list',{status:'active'}] and ['todos','list',{status:'done'}] but not ['todos','detail','a1'], because the detail key’s second element is 'detail', not 'list'. Matched queries are flagged stale and the active ones refetch immediately; their existing data stays on screen until the refetch resolves.
Step 2 — Target one query with exact: true
When you want a single query and nothing beneath it, disable fuzzy matching. exact: true requires the cached key to hash-equal the supplied key in full.
export function useExactInvalidators() {
const queryClient = useQueryClient();
return {
oneTodo: (id: string) =>
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id), exact: true }),
};
}
Cache Behavior Analysis. With exact: true, todoKeys.detail('a1') → ['todos','detail','a1'] matches only the query whose serialized key is identical; ['todos','detail','b2'] is left fresh even though it shares the ['todos','detail'] prefix. Without exact, the same call would still match only a1 here because there is nothing nested below it — but exact matters the moment a key has children, e.g. invalidating todoKeys.details() exactly would match a query literally keyed ['todos','detail'] and skip all the individual entities under it.
Step 3 — Use a predicate for non-prefix targets
Some targets are not expressible as a prefix — “all done lists across every assignee,” or “every query older than a timestamp.” A predicate runs against each cached query and you return a boolean.
export function usePredicateInvalidators() {
const queryClient = useQueryClient();
return {
doneLists: () =>
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
const filters = key[2] as { status?: string } | undefined;
return key[0] === 'todos' && key[1] === 'list' && filters?.status === 'done';
},
}),
};
}
Cache Behavior Analysis. When a predicate is present, TanStack Query ignores prefix logic and calls the function once per cached query, invalidating those for which it returns true. Here the third tuple slot — the filters object the factory embedded — is inspected directly, so only ['todos','list',{status:'done'}] matches while the active list and both details are skipped. Predicates are the escape hatch for cross-cutting targets; they cost one function call per cached query, so keep the body a shallow tuple read rather than a deep object walk.
Step 4 — Patch cache across a prefix with setQueriesData
Invalidation refetches; sometimes you already know the new value and want to write it into every affected query with no network round trip. setQueriesData takes the same filters object and runs an updater against every match.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './todo-keys';
interface Todo { id: string; title: string; status: 'active' | 'done' }
export function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: string; status: Todo['status'] }) =>
fetch(`/api/todos/${id}`, { method: 'PATCH', body: JSON.stringify({ status }) }).then((r) => r.json()),
onMutate: async ({ id, status }) => {
await queryClient.cancelQueries({ queryKey: todoKeys.all });
// Patch the entity in EVERY list variant at once via the prefix
queryClient.setQueriesData<Todo[]>({ queryKey: todoKeys.lists() }, (list) =>
list?.map((t) => (t.id === id ? { ...t, status } : t)),
);
// And the detail slot
queryClient.setQueryData<Todo>(todoKeys.detail(id), (t) => (t ? { ...t, status } : t));
},
onSettled: (_data, _err, { id }) => {
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id), exact: true });
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
Cache Behavior Analysis. setQueriesData({ queryKey: todoKeys.lists() }, updater) resolves the same prefix match as invalidateQueries — every ['todos','list', …] entry — and runs the updater against each one’s cached data synchronously, so the toggled status appears in all list views in the same tick with zero requests. Structural sharing means only the changed row gets a new reference, so React re-renders just that item. The onSettled invalidation then reconciles against the server; if the write failed, the refetch corrects the optimistic value. This pairs prefix-scoped optimistic writes with prefix-scoped verification. For a broader treatment of driving invalidation from server-side signals, see tag-based invalidation systems.
Edge Cases and Gotchas
invalidateQueries does not refetch inactive queries by default
A prefix invalidation marks every match stale, but only queries with a mounted observer refetch right away. Inactive ones refetch lazily on next mount. Override with refetchType:
// Force even inactive matches to refetch now
queryClient.invalidateQueries({ queryKey: todoKeys.lists(), refetchType: 'all' });
// Mark stale but refetch nothing now
queryClient.invalidateQueries({ queryKey: todoKeys.lists(), refetchType: 'none' });
removeQueries drops data instead of refetching it
removeQueries deletes the matching entries outright. There is no refetch, and any mounted observer falls back to its pending state. Use it to purge data that must not linger — a signed-out user’s private queries — not to refresh visible data.
// On logout: purge the entire todos branch, no refetch
queryClient.removeQueries({ queryKey: todoKeys.all });
An over-broad prefix can cancel in-flight fetches you wanted
cancelQueries({ queryKey: todoKeys.all }) in onMutate aborts every in-flight todos request, including a detail fetch unrelated to the mutation. Scope the cancel to the branch you are about to patch when unrelated fetches must survive.
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A single write refetches the entire domain and floods the network | Invalidated at the todoKeys.all prefix when only lists changed |
Narrow to todoKeys.lists(), or use a predicate to hit just the affected variants |
| A detail refetch also refetches unrelated details | exact: true omitted, so the detail prefix matched every entity under it |
Pass the full leaf key with exact: true to require a complete hash match |
| Inactive list queries stay stale and never refresh | Default refetchType only refetches active observers |
Pass refetchType: 'all' to force inactive matches, or accept lazy refetch on next mount |
| Optimistic status change appears in the detail view but not in list views | setQueryData patched only the detail slot, not the list prefix |
Use setQueriesData({ queryKey: todoKeys.lists() }, updater) to patch every list variant at once |
Frequently Asked Questions
What is the difference between invalidateQueries and removeQueries with a key factory?
invalidateQueries marks matching queries stale and refetches the active ones, keeping their data on screen during the refetch. removeQueries deletes matching cache entries outright — no refetch, and any mounted observer drops to its pending state. Use invalidateQueries to refresh data the user is looking at; use removeQueries to purge data that should not persist, such as a logged-out user’s private queries under todoKeys.all.
Does invalidateQueries with a prefix refetch inactive queries immediately?
No. By default it marks every prefix-matching query stale but only refetches those with an active observer. Inactive queries are flagged stale and refetch lazily the next time a component mounts them. To force inactive ones to refetch now, pass refetchType: 'all'; to mark stale without refetching anything, pass refetchType: 'none'.
Can I update cached data across a whole key prefix after a mutation?
Yes. setQueriesData accepts the same filters object as invalidateQueries, so passing { queryKey: todoKeys.lists() } runs your updater against every list variant in the cache at once. This lets a single mutation patch an entity in every list that displays it with no network round trip — the optimistic-update counterpart to a prefix invalidation. Follow it with an invalidateQueries in onSettled to reconcile against the server.
Related
- Query Key Factory Patterns — the parent reference on hierarchy design and why nested keys make invalidation a precise, prefix-driven API.
- Building a Type-Safe Query Key Factory — how to construct the
as constfactory and typed accessors these invalidation calls depend on. - Tag-Based Invalidation Systems — the server-signal counterpart to prefix invalidation, driving cache flushes from mutation tags rather than key hierarchy.
- Data Normalization & Query Key Design — the top-level guide connecting entity identity, key design, and the invalidation lifecycle.