Query Key Factory Patterns
Every query in TanStack Query is addressed by its key, and the key is the only thing the cache knows about a query’s identity. When those keys are typed inline at each call site — ['todos'] here, ['todos', 'active', filters] there, ['todo', id] somewhere else — they drift. A typo in one component silently forks the cache into two entries, an invalidation call misses half the queries it should flush, and a refactor that renames a filter field leaves stale keys scattered across the codebase. A query key factory fixes this by making the key array a function of a single, typed source of truth. This is the applied edge of the Data Normalization & Query Key Design discipline, and it sits directly alongside Entity Mapping Strategies, which governs how the entities behind those keys are shaped and identified.
This guide covers the concrete build: how to structure a hierarchical factory so that todoKeys.all, todoKeys.lists(), todoKeys.list(filters), and todoKeys.detail(id) nest into a prefix tree, why as const is load-bearing rather than cosmetic, and how that hierarchy powers partial invalidation through TanStack Query’s prefix-matching semantics. We reference @lukemorales/query-key-factory as a batteries-included option but build a hand-rolled factory you can read end to end.
Diagnostic Checklist
You are likely missing a query key factory if you observe any of the following:
- The same logical query is spelled differently in two files (
['todos', id]vs['todo', id]), producing two cache entries for one entity and a UI that shows stale data in one place and fresh in another. - A mutation’s
invalidateQueriescall flushes far more (or far less) than intended because the inline key does not share a prefix with the queries it should match. - Renaming a filter field means grep-and-replacing string fragments across dozens of components, and you are never sure you caught them all.
- TypeScript offers no autocomplete when you type a query key, and a misspelled key compiles cleanly and fails only at runtime as a cache miss.
- You cannot answer “which queries does this key invalidate?” without mentally simulating TanStack Query’s prefix matcher.
Prerequisites
Before building a factory, you should be comfortable with the underlying key contract. Read Designing Stable Query Keys for React Query first — it establishes why keys must be deterministic and serializable, which is the invariant a factory then enforces mechanically. You should also know that TanStack Query v5 hashes keys with a stable serializer (object property order does not affect the hash) and that invalidateQueries, removeQueries, and setQueriesData all accept a filters object whose queryKey is matched as a prefix unless exact: true is set.
Implementation 1 — The Hand-Rolled Hierarchical Factory
The factory is a plain object whose members return key arrays. The trick is that every accessor is built by extending the level above it, so the arrays nest into a prefix tree. This is what lets a single invalidation reach a whole subtree.
Steps:
- Define a
todoKeys.allroot tuple that every other key extends. - Add
lists()anddetails()grouping keys that append a discriminator segment. - Add the leaf accessors
list(filters)anddetail(id)that append the variable segment. - Apply
as constto every returned array so TypeScript preserves the literal tuple type.
// features/todos/todo-keys.ts
export interface TodoFilters {
status?: 'active' | 'done';
assignee?: string;
}
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: string) => [...todoKeys.details(), id] as const,
} as const;
// Inferred types are readonly tuples, not string[]:
// todoKeys.all → readonly ['todos']
// todoKeys.lists() → readonly ['todos', 'list']
// todoKeys.list(filters) → readonly ['todos', 'list', TodoFilters]
// todoKeys.detail(id) → readonly ['todos', 'detail', string]
Cache Behavior Impact. Because list() spreads lists() and lists() spreads all, the arrays are literal prefixes of one another at both the value level and the type level. TanStack Query hashes each key with its stable serializer, so ['todos','list',{status:'active'}] and ['todos','list',{status:'active'}] produced from two different call sites hash to the identical cache slot — there is exactly one entry per logical query. The as const assertions widen nothing: todoKeys.all is typed as readonly ['todos'], which is why the compiler can later prove that detail(id) and lists() are disjoint branches under the same root.
Configuration Trade-offs:
- The
filtersobject lives in thequeryKey, so every distinct filter combination is a distinct cache entry with its ownstaleTimeandgcTimelifecycle — a filter the user never revisits is garbage-collected independently. - Putting the whole
TodoFiltersobject in the key means TanStack Query’s serializer hashes it structurally; property order does not matter, but a strayundefinedfield versus an absent field hashes differently, so normalize filters before they reachlist(). - Keep the leaf segment serializable — an
id: stringor a plain filters object. Never place a class instance,Date, or function in the key, because the hash serializer cannot represent them stably.
Implementation 2 — Wiring the Factory Into Hooks
A factory only pays off if it is the only place keys are written. Route every useQuery, useInfiniteQuery, and useMutation through it, and colocate the factory with the feature so it moves and dies with the code it serves. The full step-by-step is in Building a Type-Safe Query Key Factory; here is the wiring shape.
Steps:
- Import
todoKeysinto the hook module and use it for thequeryKey. - Use the matching accessor for the exact granularity the hook needs —
list(filters)for a filtered list,detail(id)for one entity. - In mutations, invalidate through the factory so the mutation never hardcodes a key.
// features/todos/use-todos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys, TodoFilters } from './todo-keys';
interface Todo { id: string; title: string; status: 'active' | 'done' }
async function fetchTodoList(filters: TodoFilters): Promise<Todo[]> {
const params = new URLSearchParams(filters as Record<string, string>);
const res = await fetch(`/api/todos?${params}`);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.json();
}
export function useTodoList(filters: TodoFilters) {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodoList(filters),
staleTime: 30_000,
gcTime: 5 * 60_000,
});
}
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (title: string) =>
fetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) }).then((r) => r.json()),
onSuccess: () => {
// Fuzzy prefix match: every list variant is invalidated, no detail query is touched
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
}
Cache Behavior Impact. The queryKey: todoKeys.list(filters) call generates a readonly tuple that TanStack Query hashes to locate or create the cache entry; two components that call useTodoList with structurally equal filters subscribe to the same observer, so a single network request backs both. On mutation, invalidateQueries({ queryKey: todoKeys.lists() }) uses the default fuzzy matcher: it walks every cached query and marks stale any whose key array starts with ['todos','list']. The detail entries under ['todos','detail', …] fail the prefix test and stay fresh, so adding a todo refetches the lists without evicting the detail cache the user is currently viewing.
Configuration Trade-offs:
- Invalidating at
todoKeys.lists()re-runs every active list query; if you have many filter permutations mounted, scope tighter withtodoKeys.list(specificFilters)plusexact: trueto refetch only the affected list. staleTime: 30_000means an invalidation on a mounted list refetches immediately, but a background list that is stale-but-cached refetches only when next observed — tune against how urgently list order must reflect writes.- Because the mutation calls
invalidateQueriesinonSuccess, a failed request leaves the cache untouched; if you want optimistic behavior, move the cache write toonMutateand reconcile inonSettledinstead.
Implementation 3 — Precise Invalidation From the Hierarchy
The hierarchy is not just organizational; it is the invalidation API. Because invalidateQueries matches on queryKey prefixes by default, the level you pass decides the blast radius. This is covered in depth in Invalidating Queries With a Key Factory; the essentials follow.
Steps:
- Pass a broad prefix to flush a whole domain, a mid-level prefix to flush a branch, or a full leaf key with
exact: trueto flush one query. - Use a
predicatewhen the branch you want cannot be expressed as a simple prefix. - Reach for
removeQuerieswhen you want the data gone rather than merely refetched.
import { useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './todo-keys';
export function useTodoInvalidation() {
const queryClient = useQueryClient();
return {
everything: () => queryClient.invalidateQueries({ queryKey: todoKeys.all }),
allLists: () => queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
oneDetail: (id: string) =>
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id), exact: true }),
// Predicate: invalidate only "done" list variants
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 Impact. invalidateQueries({ queryKey: todoKeys.all }) matches every key beginning with ['todos'] — lists and details alike — so it is the domain-wide reset. { queryKey: todoKeys.detail(id), exact: true } disables prefix matching and requires a byte-for-byte hash match, so only that one entity refetches and its sibling details stay cached. The predicate form runs against every query in the cache and gives you arbitrary logic when the target set is not a clean prefix — here, “all done lists regardless of assignee.” In every case, invalidation marks queries stale and triggers a refetch of the active ones; inactive queries are marked stale and refetch lazily on next mount.
Configuration Trade-offs:
exact: truetrades reach for precision — use it when a broad prefix would refetch expensive queries you know are unaffected.- A
predicateruns for every cached query on each invalidation call; keep it cheap (index into the tuple, avoid deep scans) if the cache holds hundreds of entries. - Prefer prefix matching over
predicatewhenever the target is expressible as a prefix — it is faster and reads as data rather than logic, which is the whole reason to nest keys hierarchically.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Two cache entries exist for the same entity; one updates, the other stays stale | Keys were typed inline in two places with different spelling (['todo', id] vs ['todos','detail',id]) |
Route both call sites through todoKeys.detail(id); delete every inline key so the factory is the single source |
invalidateQueries flushes detail queries when you only meant to refresh lists |
The invalidation key ['todos'] is a prefix of both lists and details, so fuzzy matching reaches both branches |
Narrow the prefix to todoKeys.lists() so only keys starting with ['todos','list'] match |
| A single detail refetch triggers refetches of unrelated details | exact: true was omitted, so ['todos','detail'] matched every detail as a prefix |
Pass the full leaf key todoKeys.detail(id) with exact: true to require a complete hash match |
| Autocomplete is missing on key positions and a typo compiles fine | as const was omitted, so tuples widened to string[] and literal types were lost |
Add as const to every factory return value; the inferred readonly tuple restores position-level typing |
| Filter changes produce a new cache entry every keystroke | The raw, un-normalized filter object (with transient fields) is placed directly in list(filters) |
Normalize filters — strip undefined, round debounced values — before passing them to the factory accessor |
Frequently Asked Questions
Why should query keys be arrays instead of strings in React Query v5?
TanStack Query v5 hashes query keys with a deterministic serializer and performs partial matching by comparing array prefixes element by element. A flat string like 'todos-active' is opaque — the cache cannot tell it is a subset of the todos domain. An array key ['todos', 'list', { status: 'active' }] lets invalidateQueries({ queryKey: ['todos'] }) match every todos query at once via prefix matching, which a string key structurally cannot express. Arrays are the only key shape that carries hierarchy.
Does @lukemorales/query-key-factory replace a hand-rolled factory?
It automates the boilerplate. createQueryKeys generates the hierarchical structure and typed accessors for you, and mergeQueryKeys composes per-feature stores into one root object. A hand-rolled factory gives you full control over the tuple shape and lets you colocate queryFn options next to each key. Both produce the same readonly-array keys that TanStack Query matches on, so the decision is about ergonomics and team conventions, not cache behavior.
How does key hierarchy enable partial invalidation without touching unrelated queries?
invalidateQueries defaults to fuzzy matching: it marks stale every cached query whose key begins with the supplied prefix. Because the factory nests keys as ['todos'] → ['todos','list'] → ['todos','list',filters] → ['todos','detail',id], invalidating ['todos','list'] flushes every list variant but leaves ['todos','detail',id] untouched, since the detail key does not start with the list prefix. Hierarchy is precisely what makes surgical invalidation possible.
What breaks if I forget as const on the factory return values?
Without as const, TypeScript widens ['todos','detail',id] to string[], so the compiler can no longer prove that todoKeys.detail(id) shares a prefix with todoKeys.all. You lose autocomplete on key positions and the type-level guarantee that lists() is a prefix of list(filters). Runtime matching still works — TanStack Query hashes values, not types — but you forfeit the compile-time safety that is the entire reason to build a typed factory.
Related
- Data Normalization & Query Key Design — the parent section covering entity identity, key stability, and cache topology that the factory pattern operationalizes.
- Entity Mapping Strategies — how the entities addressed by these keys are shaped and identified, the sibling discipline a factory depends on.
- Designing Stable Query Keys for React Query — the key-stability contract a factory enforces mechanically, and the recommended prerequisite reading.
- Building a Type-Safe Query Key Factory — the full step-by-step build of the factory object, type helpers, and hook wiring introduced above.
- Invalidating Queries With a Key Factory — deep dive into prefix versus exact matching, predicate functions, and
removeQueriesversusinvalidateQueries.