Query keys are the foundation of TanStack Query’s caching system. They uniquely identify queries and determine when data should be refetched.
What are Query Keys?
Query keys are arrays that uniquely identify a query. They can be as simple as a single string or as complex as nested objects:
// Simple key
useQuery({ queryKey: ['todos'], queryFn: fetchAllTodos })
// Key with parameters
useQuery({ queryKey: ['todo', 5], queryFn: () => fetchTodo(5) })
// Complex key
useQuery({
queryKey: ['todos', { status: 'done', page: 1 }],
queryFn: () => fetchTodos({ status: 'done', page: 1 })
})
Query keys must be arrays in TanStack Query v4+. Single strings are no longer supported.
Query Key Structure
Query keys are serialized deterministically, so these are equivalent:
// These produce the same query hash
useQuery({ queryKey: ['todos', { status: 'done', page: 1 }] })
useQuery({ queryKey: ['todos', { page: 1, status: 'done' }] })
Query keys are hashed in a deterministic way, but object property order doesn’t matter. However, arrays are order-sensitive.
Query Key Conventions
Use Arrays
Always use arrays for query keys, even for simple queries.// Good
queryKey: ['todos']
// Bad (v3 style)
queryKey: 'todos'
Hierarchy
Structure keys from general to specific.queryKey: ['todos']
queryKey: ['todos', 'list']
queryKey: ['todos', 'list', { filter: 'done' }]
queryKey: ['todos', 'detail', todoId]
Include Dependencies
Include all variables that the query function depends on.function useTodo(todoId) {
return useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
})
}
Consistent Naming
Use consistent naming patterns across your application.// Resource-based
queryKey: ['users', userId]
queryKey: ['posts', postId]
queryKey: ['comments', commentId]
Query Key Factories
Create factories to ensure consistent query keys:
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
// Usage
useQuery({
queryKey: todoKeys.list('done'),
queryFn: () => fetchTodos('done'),
})
useQuery({
queryKey: todoKeys.detail(5),
queryFn: () => fetchTodo(5),
})
// Invalidate all todo queries
queryClient.invalidateQueries({ queryKey: todoKeys.all })
// Invalidate all todo lists
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
// Invalidate specific todo
queryClient.invalidateQueries({ queryKey: todoKeys.detail(5) })
Query key factories make it easy to manage and invalidate related queries.
Partial Matching
Query keys support partial matching for invalidation:
// Invalidate all queries starting with ['todos']
queryClient.invalidateQueries({ queryKey: ['todos'] })
// This invalidates:
// ['todos']
// ['todos', 'list']
// ['todos', 'list', { filter: 'done' }]
// ['todos', 'detail', 1]
Exact Matching
Use exact matching when you need precision:
// Only invalidate exact match
queryClient.invalidateQueries({
queryKey: ['todos', 'list'],
exact: true
})
// This invalidates:
// ['todos', 'list'] ✓
// This does NOT invalidate:
// ['todos', 'list', { filter: 'done' }] ✗
Query Key Dependencies
Always include query function dependencies in the key:
function useTodosByUser(userId: number, status: string) {
return useQuery({
// Both userId and status are in the key
queryKey: ['todos', userId, status],
queryFn: () => fetchTodosByUser(userId, status),
})
}
If you don’t include all dependencies in the query key, you may see stale data when those dependencies change.
Dynamic Query Keys
Query keys can include computed values:
function useProjects(filters: ProjectFilters) {
return useQuery({
queryKey: ['projects', filters],
queryFn: () => fetchProjects(filters),
})
}
// The query key automatically updates when filters change
const { data } = useProjects({ status: 'active', page: 1 })
Query Key Hashing
TanStack Query hashes query keys internally:
// These are treated as different queries
queryKey: ['todos', { status: 'done' }] // Hash: "[todos, {status:done}]"
queryKey: ['todos', { status: 'active' }] // Hash: "[todos, {status:active}]"
// But these are the same
queryKey: ['todos', { status: 'done', page: 1 }]
queryKey: ['todos', { page: 1, status: 'done' }] // Same hash!
Custom Query Key Hash Function
Customize how query keys are hashed:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: (queryKey) => {
// Custom hash function
return JSON.stringify(queryKey)
},
},
},
})
Accessing Query Data by Key
Retrieve cached data using query keys:
import { useQueryClient } from '@tanstack/react-query'
function TodoComponent({ todoId }) {
const queryClient = useQueryClient()
// Get data from cache
const cachedTodo = queryClient.getQueryData(['todo', todoId])
// Set data in cache
queryClient.setQueryData(['todo', todoId], newTodo)
// Remove data from cache
queryClient.removeQueries({ queryKey: ['todo', todoId] })
}
Query Key Best Practices
// Use descriptive keys
queryKey: ['users', 'profile', userId]
// Include all dependencies
queryKey: ['posts', { authorId, status }]
// Use factories for consistency
queryKey: postKeys.detail(postId)
// Order from general to specific
queryKey: ['todos', 'list', filters]
// Don't use strings (v3 style)
queryKey: 'users'
// Don't omit dependencies
queryKey: ['posts'] // Missing authorId!
// Don't use inconsistent patterns
queryKey: ['user', userId]
queryKey: [userId, 'user'] // Inconsistent order
// Don't include non-deterministic values
queryKey: ['todos', Math.random()]
TypeScript and Query Keys
Type-safe query keys with TypeScript:
type TodoQueryKey = ['todos'] | ['todos', 'list'] | ['todos', 'detail', number]
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
} satisfies Record<string, () => TodoQueryKey | TodoQueryKey[number][]>
// Usage with type safety
function useTodo(id: number) {
return useQuery({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
})
}
Invalidating with Filters
Use filters for fine-grained invalidation control:
// Invalidate all queries
queryClient.invalidateQueries()
// Invalidate queries starting with ['todos']
queryClient.invalidateQueries({ queryKey: ['todos'] })
// Invalidate exact query
queryClient.invalidateQueries({
queryKey: ['todos', 'list'],
exact: true
})
// Invalidate with predicate
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' && query.state.data?.length > 0
})
// Invalidate stale queries only
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active', // Only refetch active queries
})
Query Key Prefetching
Prefetch data using query keys:
function TodosPage() {
const queryClient = useQueryClient()
const handleMouseEnter = (todoId: number) => {
// Prefetch todo details on hover
queryClient.prefetchQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodo(todoId),
})
}
return (
<div>
{todos.map((todo) => (
<div key={todo.id} onMouseEnter={() => handleMouseEnter(todo.id)}>
{todo.title}
</div>
))}
</div>
)
}
Query Key Patterns
Common patterns for organizing query keys:
// By resource
const keys = {
users: ['users'],
posts: ['posts'],
comments: ['comments'],
}
// By feature
const dashboardKeys = {
stats: ['dashboard', 'stats'],
activity: ['dashboard', 'activity'],
reports: ['dashboard', 'reports'],
}
// By entity and operation
const userKeys = {
list: (filters) => ['users', 'list', filters],
detail: (id) => ['users', 'detail', id],
profile: (id) => ['users', 'profile', id],
settings: (id) => ['users', 'settings', id],
}