Query Key Design
Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 070
Query keys are the cache addressing scheme of TanStack Query. Good key design makes cache reuse, invalidation, prefetching, mutation reconciliation, SSR hydration, and debugging predictable.
Part 070 — Query Key Design
Query keys are not a naming convention.
They are the address space of your server-state cache.
A query key decides whether two components share data or not.
It decides whether a mutation can invalidate the right records.
It decides whether prefetching warms the cache used by the next screen.
It decides whether devtools can explain the state of your app.
A good query key strategy gives you:
- cache correctness
- predictable reuse
- safe invalidation
- readable devtools
- clean query factories
- mutation reconciliation
- route/prefetch alignment
- lower accidental refetches
A bad query key strategy gives you:
- data leaking between filters
- stale detail pages
- list/detail mismatch
- impossible invalidation
- duplicate network requests
- cache entries that never match
- hydration mismatch
- ghost bugs that look like React bugs
This part is about designing query keys as infrastructure.
1. The Core Invariant
The central invariant is simple:
Same key => same server-state value
Different value => different key
That has two consequences.
Consequence 1 — include every data-changing variable
Bad:
useQuery({
queryKey: ['cases'],
queryFn: () => api.cases.search({ status, page, sort }),
});
This says every search result is the same cache record.
Better:
useQuery({
queryKey: ['cases', 'list', { status, page, sort }],
queryFn: () => api.cases.search({ status, page, sort }),
});
Consequence 2 — do not include non-semantic variables
Bad:
useQuery({
queryKey: ['cases', { status, page, renderedAt: Date.now() }],
queryFn: () => api.cases.search({ status, page }),
});
This says every render is a different cache record.
Better:
useQuery({
queryKey: ['cases', 'list', { status, page }],
queryFn: () => api.cases.search({ status, page }),
});
A query key is the identity of data, not the identity of the render.
2. Query Key Anatomy
A strong key usually has layers.
[domain, resource-shape, identity, parameters]
Examples:
['cases']
['cases', 'list', { status: 'open', page: 1 }]
['cases', 'detail', 'C-123']
['cases', 'events', 'C-123', { limit: 50 }]
['cases', 'attachments', 'C-123']
['users', 'detail', 'U-10']
['users', 'permissions', 'U-10', { tenantId: 'T-1' }]
The first segment should usually identify the domain.
['cases', ...]
['users', ...]
['permissions', ...]
['reference-data', ...]
This makes broad invalidation possible:
queryClient.invalidateQueries({ queryKey: ['cases'] });
The second segment often identifies the shape:
list
detail
events
summary
count
permissions
This makes targeted invalidation possible:
queryClient.invalidateQueries({ queryKey: ['cases', 'list'] });
Identity and parameters come after that.
queryClient.invalidateQueries({ queryKey: ['cases', 'detail', caseId] });
3. Hierarchical Keys Enable Hierarchical Invalidation
TanStack Query supports partial query matching for many cache operations.
That means prefix design matters.
Example:
// broad: everything under cases
queryClient.invalidateQueries({ queryKey: ['cases'] });
// narrower: all case lists, but not detail records
queryClient.invalidateQueries({ queryKey: ['cases', 'list'] });
// exact detail record
queryClient.invalidateQueries({ queryKey: ['cases', 'detail', caseId] });
If your keys are not hierarchical, invalidation becomes guesswork.
Bad:
['caseList']
['caseById', id]
['eventsForCase', id]
['attachments', id]
Now there is no clean domain prefix.
Better:
['cases', 'list', filters]
['cases', 'detail', id]
['cases', 'events', id]
['cases', 'attachments', id]
The structure should match invalidation needs.
4. Key Factories
Do not hand-write keys everywhere.
Bad:
useQuery({ queryKey: ['cases', 'detail', caseId], queryFn: ... });
queryClient.invalidateQueries({ queryKey: ['case', 'detail', caseId] }); // typo
The cache now has two concepts:
cases
case
Use key factories:
export const caseKeys = {
all: () => ['cases'] as const,
lists: () => [...caseKeys.all(), 'list'] as const,
list: (filters: CaseSearchFilters) =>
[...caseKeys.lists(), normalizeCaseFilters(filters)] as const,
details: () => [...caseKeys.all(), 'detail'] as const,
detail: (caseId: string) =>
[...caseKeys.details(), caseId] as const,
events: (caseId: string, params: CaseEventParams) =>
[...caseKeys.all(), 'events', caseId, normalizeEventParams(params)] as const,
};
Usage:
useQuery({
queryKey: caseKeys.detail(caseId),
queryFn: () => api.cases.getById(caseId),
});
Invalidation:
queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) });
queryClient.invalidateQueries({ queryKey: caseKeys.lists() });
Key factories turn cache addressing into a typed API.
5. Query Options Factories
A key factory solves identity.
A query options factory solves identity + fetch function + policy.
import { queryOptions } from '@tanstack/react-query';
export const caseQueries = {
list: (filters: CaseSearchFilters) =>
queryOptions({
queryKey: caseKeys.list(filters),
queryFn: () => api.cases.search(filters),
staleTime: 10_000,
}),
detail: (caseId: string) =>
queryOptions({
queryKey: caseKeys.detail(caseId),
queryFn: () => api.cases.getById(caseId),
staleTime: 30_000,
}),
};
Then all consumers speak the same cache language:
const query = useQuery(caseQueries.detail(caseId));
Prefetching:
queryClient.prefetchQuery(caseQueries.detail(caseId));
Invalidation:
queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) });
Direct cache read:
const cachedCase = queryClient.getQueryData(caseKeys.detail(caseId));
The result:
same key
same function
same policy
same type inference
6. Object Parameters Need Normalization
Query keys can contain objects as long as they are serializable and represent stable semantic input.
But UI filter objects often contain noise:
type CaseFilters = {
status?: string | null;
assigneeId?: string | null;
page?: number;
pageSize?: number;
sort?: string | null;
q?: string;
};
Raw input may produce different object shapes for the same query:
{ status: 'open', page: 1 }
{ page: 1, status: 'open', assigneeId: null }
{ status: 'open', page: 1, q: '' }
Normalize first:
export function normalizeCaseFilters(filters: CaseFilters) {
return {
status: filters.status ?? 'all',
assigneeId: filters.assigneeId ?? 'any',
page: filters.page ?? 1,
pageSize: filters.pageSize ?? 25,
sort: filters.sort ?? 'createdAt:desc',
q: filters.q?.trim() || undefined,
};
}
Then key:
caseKeys.list(normalizeCaseFilters(filters));
This avoids accidental cache fragmentation.
7. Undefined, Null, Empty, and Default Values
Be explicit about semantic difference.
undefined = omitted / default / not provided
null = explicit no value
'' = empty string input
'all' = explicit filter option
Bad:
['cases', { status }]
where status might be:
undefined
null
'all'
''
Better:
function normalizeStatus(status: unknown): CaseStatusFilter {
if (status === 'open') return 'open';
if (status === 'closed') return 'closed';
return 'all';
}
Then:
['cases', 'list', { status: normalizeStatus(status) }]
A cache key should encode domain meaning, not raw widget state.
8. Array Order Matters
Objects in keys are treated deterministically by TanStack Query, but array order is semantic.
These should mean different things:
['cases', 'detail', caseId]
['cases', caseId, 'detail']
Pick one convention.
Recommended:
[domain, shape, identity, params]
Examples:
['cases', 'detail', caseId]
['cases', 'events', caseId, { limit }]
['users', 'permissions', userId, { tenantId }]
Do not mix conventions by team preference.
Bad mixed codebase:
['cases', 'detail', id]
['cases', id, 'events']
['case-detail', id]
['events', 'case', id]
That makes invalidation hard and devtools noisy.
9. Include All Query Function Dependencies
A query key acts like dependency identity for the query function.
Bad:
function CaseList({ tenantId, status }: Props) {
return useQuery({
queryKey: ['cases', { status }],
queryFn: () => api.cases.search({ tenantId, status }),
});
}
The query function depends on tenantId, but the key does not.
That means tenant A and tenant B can collide.
Better:
function CaseList({ tenantId, status }: Props) {
return useQuery({
queryKey: ['tenants', tenantId, 'cases', 'list', { status }],
queryFn: () => api.cases.search({ tenantId, status }),
});
}
Or:
queryKey: ['cases', 'list', { tenantId, status }]
Which one is better depends on invalidation strategy.
If many domains are tenant-scoped, a tenant prefix may be useful:
['tenants', tenantId, 'cases', 'list', filters]
['tenants', tenantId, 'users', 'detail', userId]
['tenants', tenantId, 'permissions', 'current-user']
Then tenant switch cleanup is easy:
queryClient.removeQueries({ queryKey: ['tenants', oldTenantId] });
10. Do Not Put Functions, Classes, DOM Nodes, or Dates Directly in Keys
A query key should be serializable and stable.
Bad:
useQuery({
queryKey: ['cases', filters, onSuccess],
queryFn: () => api.cases.search(filters),
});
Bad:
useQuery({
queryKey: ['report', new Date()],
queryFn: fetchReport,
});
Bad:
useQuery({
queryKey: ['element-size', elementRef.current],
queryFn: fetchSomething,
});
Use serialized domain values:
queryKey: ['report', formatDate(reportDate, 'yyyy-MM-dd')]
Or normalized timestamps:
queryKey: ['audit-log', { from: from.toISOString(), to: to.toISOString() }]
But be careful: time ranges can create high-cardinality caches.
11. Route Params and Query Keys
Route params often belong in query keys.
function CaseDetailRoute() {
const { caseId } = useParams();
const query = useQuery(caseQueries.detail(requireParam(caseId)));
return <CaseDetailScreen query={query} />;
}
Route search params also often belong in list keys.
function CaseSearchRoute() {
const filters = useCaseFiltersFromSearchParams();
const query = useQuery(caseQueries.list(filters));
return <CaseSearchScreen filters={filters} query={query} />;
}
Architecture:
URL state determines query key.
Query key determines server-state cache entry.
This is powerful because browser navigation maps naturally to cache navigation.
/cases?status=open&page=1 -> ['cases','list',{status:'open',page:1}]
/cases?status=open&page=2 -> ['cases','list',{status:'open',page:2}]
/cases/C-123 -> ['cases','detail','C-123']
12. Key Design for Lists
A list query key should include all list-shaping parameters.
type CaseListParams = {
status: 'all' | 'open' | 'closed';
assigneeId: string | 'any';
page: number;
pageSize: number;
sort: 'createdAt:desc' | 'createdAt:asc' | 'priority:desc';
q?: string;
};
Key:
['cases', 'list', params]
Important distinction:
filter changes -> different list cache entry
sort changes -> different list cache entry
page changes -> different list cache entry
pageSize changes-> different list cache entry
projection changes may need different key if server response shape changes
If the server endpoint returns different fields for compact vs full mode:
['cases', 'list', { ...params, projection: 'compact' }]
['cases', 'list', { ...params, projection: 'full' }]
Do not let different response shapes share the same key.
13. Key Design for Details
A detail key should be stable and direct.
['cases', 'detail', caseId]
If detail response changes by scope, include scope:
['cases', 'detail', caseId, { tenantId }]
If detail response changes by included relations:
['cases', 'detail', caseId, { include: ['assignee', 'sla'] }]
But be careful with relation arrays.
Normalize them:
function normalizeInclude(include: string[]) {
return [...new Set(include)].sort();
}
Then:
['cases', 'detail', caseId, { include: normalizeInclude(include) }]
Without normalization:
['assignee', 'sla']
['sla', 'assignee']
could become semantically same but structurally different.
14. Key Design for Current User and Session-Scoped Data
Do not use vague keys for authenticated user state.
Bad:
['me']
['permissions']
Better:
['session', 'current-user']
['session', 'permissions', { tenantId }]
['session', 'feature-flags', { tenantId }]
If the app can switch tenant or account, include that scope.
['tenants', tenantId, 'session', 'permissions']
On logout:
queryClient.clear();
Or remove sensitive scope:
queryClient.removeQueries({ queryKey: ['session'] });
queryClient.removeQueries({ queryKey: ['tenants', tenantId] });
Do not leave previous-user data in a shared cache after identity changes.
15. Key Design for Permissions
Permission queries deserve precision.
Bad:
['canEdit']
Better:
['permissions', 'case', caseId, 'edit']
['permissions', 'case', caseId, { actions: ['close', 'assign'] }]
['tenants', tenantId, 'permissions', 'current-user']
But beware per-action query explosion.
Instead of many small permission queries:
useQuery({ queryKey: ['permissions', 'case', caseId, 'close'], ... })
useQuery({ queryKey: ['permissions', 'case', caseId, 'assign'], ... })
useQuery({ queryKey: ['permissions', 'case', caseId, 'reopen'], ... })
Prefer a capability snapshot when the backend supports it:
useQuery({
queryKey: ['permissions', 'case', caseId],
queryFn: () => api.permissions.forCase(caseId),
});
Then derive:
const canClose = permissions.data?.actions.includes('case.close') ?? false;
Cache fewer, richer authorization records.
16. Key Design for Search
Search inputs create a special issue: raw typing is too volatile.
Bad:
const [q, setQ] = useState('');
useQuery({
queryKey: ['cases', 'search', { q }],
queryFn: () => api.cases.search({ q }),
});
This creates a cache entry for every keystroke.
Better:
const [draftQ, setDraftQ] = useState('');
const committedQ = useDebouncedValue(draftQ, 300);
const query = useQuery({
queryKey: ['cases', 'search', { q: normalizeSearch(committedQ) }],
queryFn: () => api.cases.search({ q: normalizeSearch(committedQ) }),
enabled: normalizeSearch(committedQ).length >= 2,
});
Even better for URL-driven search:
draft input -> commit to URL -> URL filters -> query key
This separates:
input editing state
committed search state
server-state cache identity
17. Key Design for Infinite Queries
Infinite queries need stable identity for the list definition, not each page.
The page param is usually managed by the infinite query mechanism, not by manually creating separate top-level list keys.
Conceptually:
['cases', 'infinite-list', filters]
Not:
['cases', 'infinite-list', filters, page]
unless your specific implementation intentionally caches pages separately outside useInfiniteQuery.
The query key should identify the infinite collection.
The page param identifies a fetch step inside that collection.
Mental model:
query key = the feed
pageParam = cursor/page inside the feed
18. Key Design for Polling / Realtime-ish Data
Polling does not require time in the key.
Bad:
['queue-summary', Date.now()]
This creates new cache entries forever.
Better:
useQuery({
queryKey: ['queue', 'summary'],
queryFn: api.queue.summary,
refetchInterval: 15_000,
staleTime: 5_000,
});
The key identifies the resource.
Refetch policy handles freshness.
If you need historical time buckets, that is different:
['queue', 'summary', { bucket: '2026-07-07T10:00:00Z' }]
Now time is semantic.
19. Key Design for Multi-Tenant Applications
Multi-tenant applications need explicit scope.
A dangerous key:
['cases', 'list', filters]
If filters does not include tenant and the API uses current tenant implicitly, cache data can leak across tenant switches.
Safer:
['tenants', tenantId, 'cases', 'list', filters]
Or:
['cases', 'list', { tenantId, ...filters }]
Use prefix based on cleanup needs.
If tenant switching should remove all tenant-specific data:
['tenants', tenantId, ...]
Then:
queryClient.removeQueries({ queryKey: ['tenants', oldTenantId] });
Security-relevant rule:
Any implicit backend scope that changes response data should be explicit in the query key or isolated by clearing cache on scope change.
20. Key Design for API Version and Projection
If the same endpoint can return different schema versions or projections, key it.
['cases', 'detail', caseId, { version: 'v2' }]
['cases', 'detail', caseId, { projection: 'summary' }]
['cases', 'detail', caseId, { projection: 'full' }]
Otherwise, one component may read data shaped for another component.
Bad:
useQuery({
queryKey: ['case', caseId],
queryFn: () => api.cases.getSummary(caseId),
});
useQuery({
queryKey: ['case', caseId],
queryFn: () => api.cases.getFull(caseId),
});
Both use the same key but return different shapes.
Better:
['cases', 'detail', caseId, { projection: 'summary' }]
['cases', 'detail', caseId, { projection: 'full' }]
Or standardize one detail endpoint.
21. Key Design for Locale and Time Zone
If server response changes by locale or timezone, include them.
['reports', 'monthly', { month, locale, timeZone }]
But if localization is purely client-side, do not include locale.
Decision:
Does this variable change the server response bytes/semantics?
If yes, include it.
If no, keep it out.
Example:
// Server returns raw ISO timestamps. UI formats locally.
['case-events', caseId]
// Server returns localized labels and date buckets.
['case-events', caseId, { locale, timeZone }]
22. Key Design and Invalidation From Mutations
Design keys backward from mutation needs.
Command:
assign case C-123 to user U-9
Affected reads:
['cases', 'detail', 'C-123']
['cases', 'list', filters] where C-123 may appear
['users', 'workload', 'U-9']
['cases', 'events', 'C-123']
Mutation handler:
const assignCase = useMutation({
mutationFn: api.cases.assign,
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: caseKeys.detail(variables.caseId) });
queryClient.invalidateQueries({ queryKey: caseKeys.lists() });
queryClient.invalidateQueries({ queryKey: userKeys.workload(variables.assigneeId) });
queryClient.invalidateQueries({ queryKey: caseKeys.events(variables.caseId, { limit: 50 }) });
},
});
If you cannot express affected reads with prefixes, your key design is too weak.
23. Broad vs Narrow Invalidation
Broad invalidation is simple but may refetch too much.
queryClient.invalidateQueries({ queryKey: ['cases'] });
Narrow invalidation is efficient but may miss related views.
queryClient.invalidateQueries({ queryKey: ['cases', 'detail', caseId] });
Choose based on domain impact.
| Mutation | Invalidation Strategy |
|---|---|
| Rename one case | detail + lists that display title |
| Change case status | detail + lists + counts/summary |
| Add comment | comments/events + maybe detail activity summary |
| Change permission role | permission queries + screens gated by permission |
| Bulk import | broad domain invalidation |
| Update reference table | reference-data prefix |
A useful pattern:
function invalidateCaseAfterStatusChange(queryClient: QueryClient, caseId: string) {
queryClient.invalidateQueries({ queryKey: caseKeys.detail(caseId) });
queryClient.invalidateQueries({ queryKey: caseKeys.lists() });
queryClient.invalidateQueries({ queryKey: caseKeys.counts() });
}
Do not duplicate invalidation logic in every component.
24. Exact Matching vs Prefix Matching
Sometimes you want exact match.
queryClient.invalidateQueries({
queryKey: ['cases'],
exact: true,
});
This targets only ['cases'], not:
['cases', 'list', filters]
['cases', 'detail', caseId]
Prefix matching is better for hierarchies.
Exact matching is better for singleton records.
Example:
['reference-data']
['reference-data', 'countries']
['reference-data', 'case-statuses']
Invalidate all reference data:
queryClient.invalidateQueries({ queryKey: ['reference-data'] });
Invalidate only root singleton if it exists:
queryClient.invalidateQueries({ queryKey: ['reference-data'], exact: true });
Know which one you are using.
25. Predicate Invalidation Is a Last Resort
Predicate invalidation is powerful.
queryClient.invalidateQueries({
predicate: (query) => {
return query.queryKey[0] === 'cases' &&
query.queryKey.includes('list');
},
});
But if you need it often, it may signal poor key shape.
Use predicates for:
- version-based invalidation
- migration cleanup
- complex debugging tooling
- temporary compatibility bridges
Prefer structured prefixes for normal domain invalidation.
26. Key Design and Prefetching
Prefetching only helps if the prefetched key equals the later observed key.
Bad:
queryClient.prefetchQuery({
queryKey: ['case', caseId],
queryFn: () => api.cases.getById(caseId),
});
Later:
useQuery({
queryKey: ['cases', 'detail', caseId],
queryFn: () => api.cases.getById(caseId),
});
The prefetched data will not match.
Better:
queryClient.prefetchQuery(caseQueries.detail(caseId));
Later:
useQuery(caseQueries.detail(caseId));
Use the same options factory.
27. Key Design and SSR Hydration
Hydration requires server and client to agree on query keys.
Bad:
// server
await queryClient.prefetchQuery({
queryKey: ['case', params.caseId],
queryFn: () => api.cases.getById(params.caseId),
});
// client
useQuery(caseQueries.detail(params.caseId));
If caseQueries.detail uses ['cases', 'detail', caseId], hydration misses.
Better:
// server
await queryClient.prefetchQuery(caseQueries.detail(params.caseId));
// client
useQuery(caseQueries.detail(params.caseId));
Same factory, same key, same policy.
Also avoid client-only values in SSR query keys:
window.innerWidth
localStorage value
Date.now()
random id
If the server cannot know it, the hydrated key may differ.
28. Key Design and Cache Memory
Every unique key can create a cache entry.
High-cardinality dimensions:
- search text
- timestamps
- cursor/page
- user-generated filters
- tenant id
- record id
- locale/timezone combinations
This is not automatically bad.
But it needs retention policy.
Example:
useQuery({
...caseQueries.search(filters),
gcTime: 60_000,
});
For expensive detail screens where back navigation matters:
useQuery({
...caseQueries.detail(caseId),
gcTime: 10 * 60_000,
});
Key design and gcTime should be considered together.
high cardinality + long gcTime = memory pressure risk
29. Key Design and Devtools Readability
Devtools should tell a story.
Readable:
['cases', 'list', { status: 'open', page: 1 }]
['cases', 'detail', 'C-123']
['cases', 'events', 'C-123', { limit: 50 }]
['users', 'detail', 'U-9']
['session', 'permissions', { tenantId: 'T-1' }]
Unreadable:
['data', 'x', 1]
['getStuff', '[object Object]']
['list2']
['abc']
Devtools are production engineering tools.
When an incident happens, your cache keys should help answer:
What is loaded?
What is stale?
What is refetching?
What was invalidated?
Which screen is observing this?
A naming strategy is operational visibility.
30. Domain Key Factory Example
A serious key factory may look like this:
export type CaseListFilters = {
tenantId: string;
status: 'all' | 'open' | 'closed';
assigneeId: string | 'any';
page: number;
pageSize: number;
sort: 'createdAt:desc' | 'createdAt:asc' | 'priority:desc';
q?: string;
};
export function normalizeCaseListFilters(input: Partial<CaseListFilters>): CaseListFilters {
return {
tenantId: requireString(input.tenantId, 'tenantId'),
status: input.status ?? 'all',
assigneeId: input.assigneeId ?? 'any',
page: Math.max(1, input.page ?? 1),
pageSize: input.pageSize ?? 25,
sort: input.sort ?? 'createdAt:desc',
q: input.q?.trim() || undefined,
};
}
export const caseKeys = {
tenant: (tenantId: string) => ['tenants', tenantId] as const,
all: (tenantId: string) =>
[...caseKeys.tenant(tenantId), 'cases'] as const,
lists: (tenantId: string) =>
[...caseKeys.all(tenantId), 'list'] as const,
list: (filters: CaseListFilters) =>
[...caseKeys.lists(filters.tenantId), normalizeCaseListFilters(filters)] as const,
details: (tenantId: string) =>
[...caseKeys.all(tenantId), 'detail'] as const,
detail: (tenantId: string, caseId: string) =>
[...caseKeys.details(tenantId), caseId] as const,
events: (tenantId: string, caseId: string, params: { limit: number }) =>
[...caseKeys.all(tenantId), 'events', caseId, { limit: params.limit }] as const,
counts: (tenantId: string) =>
[...caseKeys.all(tenantId), 'counts'] as const,
};
Query options:
export const caseQueries = {
list: (filters: Partial<CaseListFilters>) => {
const normalized = normalizeCaseListFilters(filters);
return queryOptions({
queryKey: caseKeys.list(normalized),
queryFn: () => caseApi.search(normalized),
staleTime: 10_000,
});
},
detail: (tenantId: string, caseId: string) =>
queryOptions({
queryKey: caseKeys.detail(tenantId, caseId),
queryFn: () => caseApi.getById({ tenantId, caseId }),
staleTime: 30_000,
}),
};
Invalidation helpers:
export const caseInvalidation = {
afterCaseUpdated(queryClient: QueryClient, tenantId: string, caseId: string) {
queryClient.invalidateQueries({ queryKey: caseKeys.detail(tenantId, caseId) });
queryClient.invalidateQueries({ queryKey: caseKeys.lists(tenantId) });
queryClient.invalidateQueries({ queryKey: caseKeys.counts(tenantId) });
},
afterBulkImport(queryClient: QueryClient, tenantId: string) {
queryClient.invalidateQueries({ queryKey: caseKeys.all(tenantId) });
},
};
This is what maintainable cache addressing looks like.
31. Common Anti-Patterns
Anti-Pattern 1 — Generic Keys
['list']
['detail']
['data']
Fix:
['cases', 'list', filters]
['cases', 'detail', caseId]
Anti-Pattern 2 — Missing Scope
['cases', 'list', filters]
in a tenant-scoped app.
Fix:
['tenants', tenantId, 'cases', 'list', filters]
Anti-Pattern 3 — Raw Form State as Query Key
['cases', 'search', formState]
where form state contains dirty flags, touched fields, UI-only data, and empty values.
Fix:
['cases', 'search', normalizeCommittedSearch(formState)]
Anti-Pattern 4 — Time as Cache Buster
['report', Date.now()]
Fix:
['report', reportParams]
Use invalidation/refetch policy, not cache-busting keys.
Anti-Pattern 5 — Different Shapes, Same Key
['case', id] // summary
['case', id] // full detail
Fix:
['cases', 'detail', id, { projection: 'summary' }]
['cases', 'detail', id, { projection: 'full' }]
Or standardize one response shape.
Anti-Pattern 6 — Key Construction in Components
const queryKey = ['cases', 'list', { status, page }];
everywhere.
Fix:
const query = useQuery(caseQueries.list({ status, page }));
Centralize key policy.
Anti-Pattern 7 — Invalidation by Guessing Strings
queryClient.invalidateQueries({ queryKey: ['case-list'] });
Fix:
queryClient.invalidateQueries({ queryKey: caseKeys.lists(tenantId) });
Use the same key factory.
32. Testing Query Key Factories
Query keys are infrastructure. Test them.
describe('caseKeys', () => {
it('normalizes list filters', () => {
expect(
caseKeys.list({
tenantId: 'T-1',
status: 'open',
assigneeId: 'any',
page: 1,
pageSize: 25,
sort: 'createdAt:desc',
q: '',
}),
).toEqual([
'tenants',
'T-1',
'cases',
'list',
{
tenantId: 'T-1',
status: 'open',
assigneeId: 'any',
page: 1,
pageSize: 25,
sort: 'createdAt:desc',
q: undefined,
},
]);
});
it('creates tenant-scoped detail key', () => {
expect(caseKeys.detail('T-1', 'C-123')).toEqual([
'tenants',
'T-1',
'cases',
'detail',
'C-123',
]);
});
});
This may look excessive until a production bug comes from a typo in invalidation.
33. Query Key Review Checklist
For every new query key, ask:
1. Does this key include every variable used by the query function that changes returned data?
2. Does it exclude UI-only state?
3. Is tenant/account/session scope explicit when needed?
4. Is object parameter normalized?
5. Are default values canonicalized?
6. Are response projection/version/locale/timezone included when they change server output?
7. Is the key hierarchical enough for invalidation?
8. Can broad invalidation target the domain?
9. Can narrow invalidation target one record?
10. Can prefetch and useQuery share the same factory?
11. Can SSR prefetch and client hydration share the same factory?
12. Is the key readable in devtools?
13. Is high cardinality controlled with gcTime/debounce/commit boundaries?
14. Does logout/tenant switch clean the right prefixes?
15. Is this key generated by a factory instead of hand-written ad hoc?
34. Decision Matrix
| Scenario | Key Shape |
|---|---|
| Static list | ['countries'] or ['reference-data', 'countries'] |
| Entity detail | ['cases', 'detail', caseId] |
| Filtered list | ['cases', 'list', normalizedFilters] |
| Tenant-scoped list | ['tenants', tenantId, 'cases', 'list', filters] |
| Current user | ['session', 'current-user'] |
| Tenant permissions | ['tenants', tenantId, 'session', 'permissions'] |
| Entity permissions | ['permissions', 'case', caseId] |
| Infinite list | ['cases', 'infinite-list', filters] |
| Projection-specific detail | ['cases', 'detail', caseId, { projection }] |
| Locale-specific server output | ['reports', 'monthly', { month, locale, timeZone }] |
| Polling resource | stable key + refetchInterval, no timestamp |
| Search | committed/debounced normalized search params |
35. Operational Mental Model
When debugging a TanStack Query bug, ask:
Is this the right cache address?
Then inspect:
- query key
- query function variables
- freshness policy
- invalidation path
- active observers
- cached data shape
- tenant/session scope
- prefetch key
- hydration key
Many bugs that look like “React rendered stale data” are actually:
- wrong key
- incomplete key
- non-canonical key
- inconsistent key factory
- incorrect invalidation prefix
- different server/client key
React renders what the cache gives it.
The cache gives what the key addresses.
36. Final Rule
A query key should be designed like a database index.
Not because it has the same implementation, but because it has the same architectural role:
It is how the runtime finds, reuses, invalidates, and reasons about data.
Do not treat keys as strings.
Treat them as your server-state address schema.
37. What Comes Next
Part 071 moves from reads to writes:
Mutation Design
Once a mutation changes the server, your key design will determine whether cache reconciliation is:
- targeted and boring
- broad but acceptable
- fragile and incomplete
- impossible without hacks
Good mutation design starts with good query key design.
You just completed lesson 70 in deepen practice. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.
Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.