React Query Keys: Structure, Naming, and Strategies
Query keys are the foundation of TanStack Query's caching and deduplication system. They are arrays that uniquely identify each query, and the library uses them to store, retrieve, and invalidate cached data. A well-designed key structure makes your cache predictable, enables selective invalidation, and prevents subtle bugs where similar queries conflict. This article teaches the hierarchical key architecture that scales from simple queries to complex multi-parameter filtering.
After debugging a stale-cache bug in a large admin dashboard, I realized the team's query keys were inconsistent and conflicting. Some used ['posts', id], others ['post', id]. We started using a strict naming convention (plural for collections, structured by resource type) and immediately saw fewer cache collisions and easier invalidation. This structure saved us from re-architecting later.
How Query Keys Enable Caching and Deduplication
A query key is an array of primitive values that uniquely identifies a query. TanStack Query hashes the array to create a cache entry, and whenever you call useQuery with the same key, it retrieves the cached data instead of fetching again. For example:
// First call from Component A
const { data: user1 } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetch('/api/users/1').then(r => r.json()),
});
// Second call from Component B with the same key
const { data: user1Again } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetch('/api/users/1').then(r => r.json()),
});
// user1Again uses the cache, no second HTTP request
If the keys differ, the library treats them as separate queries:
const { data: user1 } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetch('/api/users/1').then(r => r.json()),
});
const { data: user2 } = useQuery({
queryKey: ['user', 2],
queryFn: () => fetch('/api/users/2').then(r => r.json()),
});
// These are different cache entries; both requests execute
The power comes from selective invalidation: you can invalidate all queries with a key matching a pattern, triggering a refetch across your entire app with one call.
Designing Hierarchical Query Keys
The best key structure mirrors your API and data hierarchy. Use a plural noun for collections and singular for individual resources:
// Collection queries
queryKey: ['posts'] // All posts
queryKey: ['posts', { status: 'published' }] // Filtered posts
queryKey: ['users', 1, 'posts'] // Posts by user 1
queryKey: ['posts', { tag: 'react', page: 2 }] // Paginated tagged posts
// Individual resource queries
queryKey: ['user', 1] // User with ID 1
queryKey: ['post', 42] // Post with ID 42
queryKey: ['user', 1, 'settings'] // User 1's settings
Each level adds specificity. The first element is the resource type; subsequent elements add context (filters, relationships, IDs). This design enables powerful bulk invalidation:
// Invalidate all user queries
queryClient.invalidateQueries({
queryKey: ['user'],
exact: false, // Match keys that start with ['user']
});
// Invalidate only user 1's queries
queryClient.invalidateQueries({
queryKey: ['user', 1],
exact: false,
});
// Invalidate only user 1's posts
queryClient.invalidateQueries({
queryKey: ['user', 1, 'posts'],
exact: false,
});
The exact: false option matches any key that begins with the specified prefix, so ['user', 1] matches ['user', 1, 'posts'], ['user', 1, 'settings'], and ['user', 1] itself.
Using Objects for Filter Parameters
When you have multiple filter options (pagination, sorting, search), use an object as a key element instead of a long array:
function SearchUsers({ query, page, sortBy }) {
const { data: results } = useQuery({
queryKey: ['users', { query, page, sortBy }],
queryFn: async () => {
const params = new URLSearchParams({ query, page, sortBy });
const res = await fetch(`/api/users?${params}`);
return res.json();
},
});
return <div>{results.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}
Objects in query keys must be serializable and are compared by value (not reference), so { query: 'react', page: 1 } and { query: 'react', page: 1 } are treated as the same key even if they are different objects. This is safe because TanStack Query serializes keys using stable JSON stringification.
Invalidation and Mutation Strategies
When you mutate data on the server, invalidate the relevant cache entries to trigger a refetch:
function useAddPost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newPost) => {
const res = await fetch('/api/posts', {
method: 'POST',
body: JSON.stringify(newPost),
});
return res.json();
},
onSuccess: () => {
// Invalidate all posts queries so they refetch
queryClient.invalidateQueries({
queryKey: ['posts'],
exact: false,
});
},
});
}
Whenever a new post is created, this mutation invalidates every cache entry starting with ['posts'], including ['posts'], ['posts', { page: 2 }], and user-specific posts. The library marks these entries as stale, and any mounted component with that query refetches automatically.
Avoiding Query Key Anti-Patterns
Common mistakes that lead to cache collisions:
| Anti-Pattern | Problem | Fix |
|---|---|---|
Non-deterministic keys (e.g., [Math.random()]) | New key every time, no caching | Use only deterministic data from props/state |
Inconsistent naming (['post', id] vs ['posts', id]) | Same resource has multiple cache entries | Standardize on plural or singular per resource type |
Storing objects by reference (e.g., [filterObj]) | Reference changes even if values match, breaking cache hits | Extract primitive values: { name, age } → ['users', { name, age }] |
Overly specific keys (['user', 1, 'profile', 'edit', 'form']) | Hard to invalidate related data | Use shorter hierarchies with 2–3 levels max |
Mixing computed and raw data (e.g., ['posts', status.toUpperCase()]) | Non-obvious key structure | Normalize data before building keys |
Query Key Factory Pattern
For large apps, use a factory object to generate keys consistently:
// queryKeys.js — centralized key factory
export const userQueryKeys = {
all: ['user'] as const,
lists: () => [...userQueryKeys.all, 'list'] as const,
list: (filters) => [...userQueryKeys.lists(), filters] as const,
details: () => [...userQueryKeys.all, 'detail'] as const,
detail: (id) => [...userQueryKeys.details(), id] as const,
settings: (id) => [...userQueryKeys.detail(id), 'settings'] as const,
};
export const postQueryKeys = {
all: ['post'] as const,
lists: () => [...postQueryKeys.all, 'list'] as const,
list: (filters) => [...postQueryKeys.lists(), filters] as const,
details: () => [...postQueryKeys.all, 'detail'] as const,
detail: (id) => [...postQueryKeys.details(), id] as const,
};
Now use this factory in components and mutations:
// In a component
const { data: user } = useQuery({
queryKey: userQueryKeys.detail(userId),
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
});
// In a mutation
const { mutate: updateUser } = useMutation({
mutationFn: (updates) => fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(updates),
}).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: userQueryKeys.detail(userId),
exact: true, // Invalidate only this user
});
},
});
This pattern ensures every key is generated the same way, prevents typos, and makes bulk invalidation trivial.
Key Takeaways
- Query keys are arrays that uniquely identify cached data; TanStack Query uses them for deduplication and selective invalidation.
- Use hierarchical keys with 2–4 levels: resource type, ID, sub-resource, filters (e.g.,
['user', 1, 'posts', { page: 2 }]). - Objects in keys must be serializable; TanStack Query compares them by stable value, not reference.
- Use a key factory to generate keys consistently across your app, preventing typos and simplifying bulk invalidation.
exact: falseininvalidateQueriesmatches any key starting with the prefix, enabling powerful selective cache clearing.- Avoid non-deterministic keys, inconsistent naming, and overly specific hierarchies that make invalidation fragile.
Frequently Asked Questions
Can I use functions or objects as query key elements?
No, query key elements must be JSON-serializable primitives (strings, numbers, booleans, null) or plain objects. Functions, Dates, and class instances will cause errors. For Dates, convert to ISO strings: ['events', new Date().toISOString()].
How does TanStack Query compare query keys?
It serializes each key to a stable JSON string using JSON.stringify, then compares the strings. Two keys with the same primitive values in the same order are treated as identical, even if the objects are different references. Object properties are sorted alphabetically.
What happens if I change a query key while a component is mounted?
If the key changes, TanStack Query treats it as a new query. The old cache entry is preserved (unless garbage collection runs), and a new fetch is triggered with the new key. This is useful for dynamic queries: when a filter changes, the key changes, and the library automatically fetches new data.
Should I include request headers or authentication tokens in the key?
No. Query keys should contain only user-facing data (filters, IDs, pagination). Authentication tokens are implementation details handled by the fetcher function. Including tokens in keys would bloat the cache and create unnecessary cache misses.
How do I invalidate multiple unrelated query types at once?
Call queryClient.invalidateQueries() multiple times, or use an array of keys in a custom loop. There is no built-in "invalidate all" function, which is intentional: you should know exactly which cache entries need refreshing.