
You've set up React Query in your application to handle server state, feeling confident about your implementation. But as your app grows, you start noticing issues: stale data appearing unexpectedly, memory usage creeping up, and that sinking feeling when you realize you might have shipped broken code because you missed invalidating some dependent queries.
"You need to have explicit knowledge of every query that depends on the data being updated, and there's no built-in way to catch missing invalidations, other than manually testing," as one developer pointed out on Reddit. This makes teams "worried about long-term scalability since we could end up shipping broken code to users without any warnings."
If these concerns resonate with you, you're not alone. Managing query keys and cache invalidation in React Query can be challenging, especially as your application scales. But with the right strategies and patterns, you can build a robust and maintainable caching system.
Understanding Query Keys: The Foundation
Before diving into advanced patterns, let's establish a solid understanding of query keys in React Query. Query keys are unique identifiers that React Query uses to cache and track your queries. They can be as simple as a string or as complex as a nested array of values.
Here's a basic example:
// Simple query key
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// Query key with variables
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodoById(todoId)
})
// Complex query key with filters
useQuery({
queryKey: ['todos', { status: 'done', userId: 1 }],
queryFn: () => fetchTodosByFilter({ status: 'done', userId: 1 })
})
What makes React Query's key system powerful is its deterministic hashing. This means:
The order of object properties in your query keys doesn't matter
Arrays and objects are hashed consistently
You can use these keys for precise cache control
For example, these query keys are considered equivalent:
['todos', { status: 'done', userId: 1 }]
['todos', { userId: 1, status: 'done' }]
This deterministic behavior is crucial for reliable cache management, especially when you need to invalidate specific queries or groups of queries.
Organizing Query Keys: From Simple to Complex
As your application grows, you'll need different strategies for organizing query keys. Let's explore these approaches, starting from the simplest to more sophisticated patterns.
1. Co-location Pattern
The co-location pattern keeps query logic close to where it's used. This is often the first approach developers take and works well for smaller applications.
// UserProfile.tsx
function UserProfile({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId)
})
return (/* render component */)
}
Advantages:
Simple to understand and implement
Clear dependencies between components and their data
Easy to maintain in smaller applications
Disadvantages:
Can lead to duplication in larger applications
Harder to maintain consistent query keys across the application
No centralized control over cache invalidation
2. Custom Hooks Pattern
As applications grow, developers often move to custom hooks to reduce duplication and improve maintainability. This pattern encapsulates query logic while maintaining the benefits of co-location.
// hooks/useUser.ts
export function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})
}
// hooks/useUserPosts.ts
export function useUserPosts(userId: string) {
return useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchUserPosts(userId)
})
}
// UserProfile.tsx
function UserProfile({ userId }) {
const { data: user } = useUser(userId)
const { data: posts } = useUserPosts(userId)
return (/* render component */)
}
This approach addresses one of the common pains developers face: "Desire to maintain a clear separation between API logic and UI components." As mentioned in this Reddit discussion, many developers want to "separate the API layer from UI components."
3. Query Key Factory Pattern
For larger applications, the Query Key Factory pattern provides a more structured approach to managing query keys. This pattern centralizes key creation and helps prevent inconsistencies.
// queryKeys.ts
export const queryKeys = {
users: {
all: ['users'] as const,
detail: (id: string) => ['users', id] as const,
posts: (id: string) => ['users', id, 'posts'] as const,
},
posts: {
all: ['posts'] as const,
detail: (id: string) => ['posts', id] as const,
comments: (id: string) => ['posts', id, 'comments'] as const,
},
}
// hooks/useUser.ts
import { queryKeys } from './queryKeys'
export function useUser(id: string) {
return useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => fetchUser(id)
})
}
// hooks/useUserPosts.ts
export function useUserPosts(userId: string) {
return useQuery({
queryKey: queryKeys.users.posts(userId),
queryFn: () => fetchUserPosts(userId)
})
}
This pattern is particularly valuable when you need to invalidate related queries. For example, after updating a user's post:
const queryClient = useQueryClient()
// Invalidate all queries related to this user's posts
queryClient.invalidateQueries({
queryKey: queryKeys.users.posts(userId)
})
// Invalidate all user-related queries
queryClient.invalidateQueries({
queryKey: queryKeys.users.all
})
4. Advanced Query Options Pattern
For complex applications with specific caching needs, you can create a more sophisticated pattern that combines query keys with standardized options:
// queryOptions.ts
interface QueryConfig {
staleTime?: number
cacheTime?: number
retry?: boolean | number
}
export const createQueryOptions = <T>(
key: readonly unknown[],
fetcher: () => Promise<T>,
config?: QueryConfig
) => ({
queryKey: key,
queryFn: fetcher,
staleTime: config?.staleTime ?? 5 * 60 * 1000, // 5 minutes
cacheTime: config?.cacheTime ?? 30 * 60 * 1000, // 30 minutes
retry: config?.retry ?? 3,
})
// hooks/useUser.ts
export function useUser(id: string) {
return useQuery(
createQueryOptions(
queryKeys.users.detail(id),
() => fetchUser(id),
{ staleTime: 60 * 1000 } // Override default staleTime
)
)
}
This pattern helps address the concern that "You have to guess when the data is not likely to be needed in memory. It can't be too short that the cache is useless, and too long that you'll get a memory leak," as mentioned in this discussion.
Effective Cache Invalidation Strategies
Cache invalidation is one of the two hard problems in computer science (along with naming things and off-by-one errors). Let's explore strategies to make it more manageable.
1. Centralized Invalidation Service
Create a service to manage all your cache invalidation logic in one place:
// services/queryInvalidation.ts
export class QueryInvalidationService {
constructor(private queryClient: QueryClient) {}
// Invalidate all queries for a specific user
invalidateUser(userId: string) {
this.queryClient.invalidateQueries({
queryKey: queryKeys.users.detail(userId)
})
}
// Invalidate user's posts
invalidateUserPosts(userId: string) {
this.queryClient.invalidateQueries({
queryKey: queryKeys.users.posts(userId)
})
}
// Invalidate specific post and related queries
invalidatePost(postId: string) {
this.queryClient.invalidateQueries({
queryKey: queryKeys.posts.detail(postId)
})
this.queryClient.invalidateQueries({
queryKey: queryKeys.posts.comments(postId)
})
}
}
// hooks/useMutations.ts
export function useUpdatePost() {
const queryClient = useQueryClient()
const invalidationService = new QueryInvalidationService(queryClient)
return useMutation({
mutationFn: updatePost,
onSuccess: (data, variables) => {
invalidationService.invalidatePost(variables.id)
}
})
}
This approach addresses the concern that "invalidating data becomes tricky" and you need "explicit knowledge of every query that depends on the data being updated," as noted in this Reddit discussion.
2. Optimistic Updates with Rollback
Instead of waiting for server responses, update the cache immediately and roll back if the mutation fails:
function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: queryKeys.todos.detail(newTodo.id)
})
// Snapshot the previous value
const previousTodo = queryClient.getQueryData(
queryKeys.todos.detail(newTodo.id)
)
// Optimistically update
queryClient.setQueryData(
queryKeys.todos.detail(newTodo.id),
newTodo
)
return { previousTodo }
},
onError: (err, newTodo, context) => {
// Roll back on error
queryClient.setQueryData(
queryKeys.todos.detail(newTodo.id),
context.previousTodo
)
},
onSettled: (newTodo) => {
// Refetch after error or success
queryClient.invalidateQueries({
queryKey: queryKeys.todos.detail(newTodo.id)
})
},
})
}
### 3. Selective Invalidation with Predicate Functions
For more granular control over cache invalidation, use predicate functions:
```typescript
// Invalidate only completed todos
queryClient.invalidateQueries({
queryKey: queryKeys.todos.all,
predicate: (query) => {
const [, filters] = query.queryKey
return filters?.status === 'completed'
},
})
4. Background Data Updates
Keep your cache fresh by implementing background updates:
function useTodoList() {
return useQuery({
queryKey: queryKeys.todos.all,
queryFn: fetchTodos,
// Refetch on window focus
refetchOnWindowFocus: true,
// Refetch every 60 seconds
refetchInterval: 60 * 1000,
// Don't refetch if the window is not visible
refetchIntervalInBackground: false,
})
}
This strategy helps prevent the issue where "the underlying data might get changed by another process and then your process that uses the cache will be working with incorrect data," as mentioned in this discussion.
Testing and Debugging Cache Invalidation
One of the biggest challenges with cache invalidation is ensuring it works correctly. Here are some strategies to help:
1. Developer Tools Integration
React Query comes with built-in DevTools that help visualize your cache:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<>
<QueryClientProvider client={queryClient}>
{/* Your app components */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</>
)
}
2. Testing Utilities
Create test utilities to verify cache invalidation:
// test-utils.ts
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {},
},
})
}
// invalidation.test.ts
test('invalidates todo list after adding new todo', async () => {
const queryClient = createTestQueryClient()
// Setup initial cache
await queryClient.prefetchQuery({
queryKey: queryKeys.todos.all,
queryFn: () => ['todo1', 'todo2']
})
// Perform mutation
await queryClient.executeMutation({
mutationFn: () => Promise.resolve('todo3'),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.todos.all
})
}
})
// Verify cache was invalidated
expect(queryClient.getQueryState(queryKeys.todos.all)?.isInvalidated).toBe(true)
})
## Common Pitfalls and Solutions
### 1. Over-invalidation
**Problem:**
Invalidating too many queries unnecessarily can lead to performance issues and unnecessary network requests.
**Solution:**
Use more specific query keys and selective invalidation:
```typescript
// ❌ Don't invalidate everything
queryClient.invalidateQueries()
// ✅ Do invalidate specific queries
queryClient.invalidateQueries({
queryKey: queryKeys.todos.detail(todoId)
})
2. Stale While Revalidate Issues
Problem:Users seeing stale data momentarily before the fresh data loads.
Solution:Implement optimistic updates or adjust staleTime
:
function useTodoList() {
return useQuery({
queryKey: queryKeys.todos.all,
queryFn: fetchTodos,
// Keep data fresh for 30 seconds
staleTime: 30 * 1000,
// Show stale data while fetching
keepPreviousData: true,
})
}
3. Memory Leaks
Problem:As mentioned in this discussion, "Using bare use query in components can cause memory leaks."
Solution:Properly configure cacheTime
and use custom hooks:
// hooks/useTodo.ts
export function useTodo(id: string) {
return useQuery({
queryKey: queryKeys.todos.detail(id),
queryFn: () => fetchTodo(id),
// Remove from cache after 1 hour of inactivity
cacheTime: 60 * 60 * 1000,
// Consider data stale after 5 minutes
staleTime: 5 * 60 * 1000,
})
}
4. Missing Invalidations
Problem:Forgetting to invalidate dependent queries after mutations.
Solution:Create a comprehensive invalidation service:
class TodoInvalidationService {
constructor(private queryClient: QueryClient) {}
invalidateAfterCreate(newTodo: Todo) {
// Invalidate list queries
this.queryClient.invalidateQueries({
queryKey: queryKeys.todos.all
})
// Invalidate user's todo list
this.queryClient.invalidateQueries({
queryKey: queryKeys.users.todos(newTodo.userId)
})
// Update statistics
this.queryClient.invalidateQueries({
queryKey: queryKeys.statistics.todos
})
}
}
## Best Practices for Scaling
As your application grows, following these best practices will help maintain a robust caching strategy:
### 1. Consistent Query Key Structure
Maintain a consistent structure for your query keys across the application:
```typescript
// queryKeys.ts
export const queryKeys = {
all: ['todos'] as const,
lists: () => [...queryKeys.all, 'list'] as const,
list: (filters: string) => [...queryKeys.lists(), { filters }] as const,
details: () => [...queryKeys.all, 'detail'] as const,
detail: (id: number) => [...queryKeys.details(), id] as const,
}
2. Type-Safe Query Keys
Use TypeScript to ensure type safety for your query keys:
// types.ts
type QueryKeys = {
todos: {
all: readonly ['todos']
detail: (id: string) => readonly ['todos', string]
}
}
// queryKeys.ts
export const queryKeys: QueryKeys = {
todos: {
all: ['todos'] as const,
detail: (id: string) => ['todos', id] as const,
}
}
3. Standardized Query Options
Create standard configurations for different types of queries:
// queryConfig.ts
export const queryConfig = {
// For frequently changing data
realtime: {
staleTime: 0,
cacheTime: 30 * 1000, // 30 seconds
refetchInterval: 1000, // 1 second
},
// For relatively stable data
static: {
staleTime: 24 * 60 * 60 * 1000, // 24 hours
cacheTime: 30 * 24 * 60 * 60 * 1000, // 30 days
},
// For user-specific data
user: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 60 * 60 * 1000, // 1 hour
},
}
4. Organized Folder Structure
Maintain a clear folder structure for your React Query related code:
src/
queries/
keys/
index.ts
todoKeys.ts
userKeys.ts
hooks/
todo/
useCreateTodo.ts
useUpdateTodo.ts
useTodoList.ts
user/
useUser.ts
useUserPosts.ts
services/
invalidation/
todoInvalidation.ts
userInvalidation.ts
config/
queryConfig.ts
queryClient.ts
Conclusion
Managing query keys and cache invalidation in React Query doesn't have to be overwhelming. By implementing the right patterns and strategies based on your application's needs, you can build a maintainable and efficient caching system.
Remember these key takeaways:
Start Simple: Begin with co-location and move to more complex patterns as needed
Centralize Logic: Use query key factories and invalidation services to maintain consistency
Plan for Scale: Implement type-safe solutions and organized folder structures
Monitor and Test: Utilize React Query DevTools and write comprehensive tests
As one developer noted in a Reddit discussion, while "invalidating data becomes tricky," having a solid strategy and proper tooling makes it manageable. By following the patterns and practices outlined in this article, you can build robust applications that effectively manage server state while avoiding common pitfalls.
Additional Resources
Remember, the key to successful cache management is finding the right balance between complexity and maintainability for your specific use case. Start with simpler patterns and evolve your approach as your application's needs grow.