React|Server State and Async Data

Efficient Query Key Management Strategy: Boosting Maintainability with the Factory Pattern

0
Efficient Query Key Management Strategy: Boosting Maintainability with the Factory Pattern

Chris is staring at his monitor with a serious look on his face. He just wrote a post and returned to the list view, but the new post isn't showing up.

"I definitely cleared the cache with invalidateQueries, so why isn't it working?"

After digging through the code, the culprit turned out to be a typo.

  • Fetching data (useQuery): ['todos', 'list', { status: 'done' }]
  • Clearing cache (invalidate): ['todo', 'list'] (The 's' is missing!)
  • In TanStack Query, the Query Key is the unique identifier for your data and the core of the caching system. However, if you hardcode these as string arrays in every component, mistakes like the one above are inevitable.

    Today, we will escape the swamp of "Magic Strings" and introduce the Query Key Factory Pattern, a strategy to create maintainable and robust query keys.

    1. The Problem with Hardcoding

    At first, using arrays directly doesn't seem to be a problem.

    // ❌ Bad Example: Component A
    useQuery({ queryKey: ['users', 'list', filter], ... });
    
    // ❌ Bad Example: Component B (Written by another developer)
    // Wrote 'user' instead of 'users' -> Cache is NOT shared
    useQuery({ queryKey: ['user', 'list', filter], ... });
    
    // ❌ Bad Example: Mutation
    // Forgot the filter condition -> The specific data we want isn't updated
    queryClient.invalidateQueries({ queryKey: ['users'] });

    As the project grows, it becomes impossible to track where and how keys are defined. If you want to change the structure, you have to search through the entire project and replace strings manually. This is a disaster waiting to happen.

    2. The Solution: Query Key Factory Pattern

    The solution is simple. "Centralize the logic for generating keys in one place."

    Just like a factory, you create an object that churns out keys based on consistent rules whenever you request them.

    Designing the Factory Object

    Usually, we stack layers in the order of Domain > Scope > Parameters.

    // queryKeys.ts
    
    export const todoKeys = {
      // 1. Top-level key (Everything related to todos)
      all: ['todos'] as const,
    
      // 2. List-related keys
      lists: () => [...todoKeys.all, 'list'] as const,
      // List key with specific filters applied
      list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
    
      // 3. Single item detail keys
      details: () => [...todoKeys.all, 'detail'] as const,
      detail: (id: number) => [...todoKeys.details(), id] as const,
    };

    By defining them with as const, TypeScript correctly infers the contents of the array as literal types.

    3. Practical Application: A World Without Typos

    Now, there is no need to manually type keys in components. Autocomplete guides us.

    Fetching Data (useQuery)

    // TodoList.tsx
    import { todoKeys } from './queryKeys';
    
    function TodoList({ filter }) {
      // ✅ IDE autocompletes todoKeys.list(filter) for you.
      const { data } = useQuery({
        queryKey: todoKeys.list(filter), 
        queryFn: () => fetchTodos(filter),
      });
      
      // ...
    }

    Invalidating Cache (invalidateQueries)

    The true value of this pattern shines when managing the cache. Thanks to the hierarchical structure, you can target the exact scope you want.

    // TodoMutation.tsx
    const queryClient = useQueryClient();
    
    const mutation = useMutation({
      mutationFn: createTodo,
      onSuccess: () => {
        // 1. Invalidate ALL queries related to Todos (both lists and details)
        // queryClient.invalidateQueries({ queryKey: todoKeys.all });
    
        // 2. Invalidate only Todo 'lists' (Details pages remain untouched)
        // queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    
        // ✅ What we want: Refresh the entire list scope
        queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
      },
    });

    Now Chris doesn't have to worry about whether to write todo or todos. If he uses what the factory object provides, it is automatically the Single Source of Truth.

    4. Leveraging Libraries (@lukemorales/query-key-factory)

    If creating the objects manually feels tedious, using a community-loved library is also a great option.

    // Example using query-key-factory
    import { createQueryKeys } from '@lukemorales/query-key-factory';
    
    export const todos = createQueryKeys('todos', {
      list: (filters: string) => ({
        queryKey: [{ filters }],
        queryFn: () => api.getTodos(filters),
      }),
      detail: (todoId: string) => ({
        queryKey: [todoId],
        queryFn: () => api.getTodo(todoId),
      }),
    });
    
    // Usage
    useQuery(todos.list('done'));

    You can even bundle the queryFn together, resulting in much higher cohesion.

    Key Takeaways

  • Problem: Hardcoding Query Keys as string arrays opens the gates to typo hell and management nightmares.
  • Solution: Use the Factory Pattern to centralize and modularize key generation.
  • Benefits:

  • Now we have perfectly mastered how to fetch and manage data. But we aren't done yet. The peak of user experience depends on how elegantly you handle "Loading" and "Error" states.

    It's time to escape the repetitive if (loading) return <Spinner /> loop and meet Suspense and Error Boundary.

    Continuing in: “Declarative Async Handling: Gracefully Managing Loading/Errors with Suspense and Error Boundary”

    🔗 References

  • TanStack Query - Query Keys
  • TkDodo - Effective React Query Keys
  • @lukemorales/query-key-factory