public-rules › TanStack Query techs/tanstack-query

Define hierarchical query keys and options in factories

MEDIUM1.0.0
When to apply Before planning, writing, changing, or reviewing TanStack Query keys that several components, prefetches, or invalidations share, or when adding queries for a new entity.

Build each entity's query keys from general to specific, such as entity, then kind, then ID or filters, and define them, together with their query functions, in one factory per entity. Use the factory everywhere the query is read, prefetched, updated, or invalidated.

Implementation

  • Start every key for an entity with the same prefix, such as ['todos'], then add a kind such as 'list' or 'detail', then the ID or filters.
  • Define factory functions that return queryOptions({ queryKey, queryFn, ... }), so the key, query function, and per-query options stay together and types flow to useQuery, prefetchQuery, and getQueryData.
  • Expose prefix helpers, such as todoQueries.all() or todoKeys.lists(), for invalidation at each level.
  • A small app with a handful of queries can inline keys; add a factory when the same key is written in more than one place.

Rationale

TanStack Query matches keys by prefix for invalidation and cache filters. A consistent hierarchy lets one call target exactly the right level, such as every list of todos or one todo and its sub-resources. Keys typed by hand in many files drift, such as 'todo' in one place and 'todos' in another, and the mismatch silently skips cache entries.

Examples

Incorrect (counterexample):

useQuery({ queryKey: ['todos', 'list', filters], queryFn: () => fetchTodos(filters) });
useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id) });

queryClient.invalidateQueries({ queryKey: ['todos'] });

The detail query uses 'todo', so invalidating ['todos'] misses it.

Correct:

export const todoQueries = {
  all: () => ['todos'] as const,
  lists: () => [...todoQueries.all(), 'list'] as const,
  list: (filters: TodoFilters) =>
    queryOptions({
      queryKey: [...todoQueries.lists(), filters] as const,
      queryFn: () => fetchTodos(filters),
    }),
  detail: (id: number) =>
    queryOptions({
      queryKey: [...todoQueries.all(), 'detail', id] as const,
      queryFn: () => fetchTodo(id),
      staleTime: 5 * 60 * 1000,
    }),
};

useQuery(todoQueries.detail(id));
await queryClient.prefetchQuery(todoQueries.list({ status: 'active' }));
queryClient.invalidateQueries({ queryKey: todoQueries.lists() });
queryClient.invalidateQueries({ queryKey: todoQueries.all() });

Validation

Search for query keys written as literals outside the factory, and check that each entity's keys share one prefix.

Inline keys in a small app where each key appears once are not a violation.