Managing Query Keys for Cache Invalidation in React Query

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:

  1. The order of object properties in your query keys doesn't matter

  2. Arrays and objects are hashed consistently

  3. 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:

  1. Start Simple: Begin with co-location and move to more complex patterns as needed

  2. Centralize Logic: Use query key factories and invalidation services to maintain consistency

  3. Plan for Scale: Implement type-safe solutions and organized folder structures

  4. 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.

Raymond Yeh

Raymond Yeh

Published on 16 March 2025

Get engineers' time back from marketing!

Don't let managing a blog on your site get in the way of your core product.

Wisp empowers your marketing team to create and manage content on your website without consuming more engineering hours.

Get started in few lines of codes.

Choosing a CMS
Related Posts
The Complete Guide to React Query's useMutation: Everything You Need to Know

The Complete Guide to React Query's useMutation: Everything You Need to Know

Master React Query useMutation with TypeScript examples. Comprehensive tutorial on form handling, API integration, and state management for React developers.

Read Full Story
Should I Use TanStack Query When Most of My Components Are Server Components?

Should I Use TanStack Query When Most of My Components Are Server Components?

Learn why TanStack Query remains crucial in a server-component-heavy architecture and see its impact on UI performance and developer efficiency.

Read Full Story
Why Senior Developers Choose Tanstack Query Over Fetch and useEffect

Why Senior Developers Choose Tanstack Query Over Fetch and useEffect

Why rely on fetch and useEffect for data fetching? Discover why senior developers prefer Tanstack Query for better performance and maintainability.

Read Full Story
Loading...