skip to content

How would you structure TanStack Query v5 keys for a todos feature with a key factory, and why nest them from general to specific?

level: middleimportance: should knowfreq 55%

answer

  1. one place builds every key
  2. each level extends the one above
  3. a prefix names a whole group
  4. list and detail segments apart
  5. queryOptions keeps key and function together

basics

~20 s

Build every key from one factory, nested from general to specific: ['todos'], then ['todos', 'list'] with filters, then ['todos', 'detail', id]. Nesting lets one prefix address a whole group, and queryOptions() keeps each key with its queryFn and type.

solid answer

~40 s

Keep all keys for the feature in one factory object, so no component hand-writes an array. Build each level from the one above: `all: ['todos']`, `lists: [...all, 'list']`, `list(filters): [...lists, filters]`, `details: [...all, 'detail']`, `detail(id): [...details, id]`. The nesting matters because TanStack Query can address keys by prefix, so the shape decides which groups one call can reach: everything about todos, every list whatever its filters, or one detail. Separate `'list'` and `'detail'` segments keep those groups from overlapping. In v5 the factory's leaves are usually `queryOptions()` calls. They keep the key, the `queryFn` and shared options such as `staleTime` together, so the key cannot drift from the variables the function uses. They also tag the key with its data type, so `queryClient.getQueryData(todoQueries.detail(id).queryKey)` is typed. `infiniteQueryOptions()` does the same for infinite queries.

code

ts · 33 lines
ts
import { queryOptions } from '@tanstack/react-query'

export type TodoFilters = { status: 'all' | 'open' | 'done'; page: number }
export type Todo = { id: number; title: string; done: boolean }

async function getJson<T>(url: string): Promise<T> {
  const res = await fetch(url)
  if (!res.ok) throw new Error(`Request failed: ${res.status}`)
  return res.json() as Promise<T>
}

export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}

export const todoQueries = {
  list: (filters: TodoFilters) =>
    queryOptions({
      queryKey: todoKeys.list(filters),
      queryFn: () =>
        getJson<Todo[]>(`/api/todos?status=${filters.status}&page=${filters.page}`),
    }),
  detail: (id: number) =>
    queryOptions({
      queryKey: todoKeys.detail(id),
      queryFn: () => getJson<Todo>(`/api/todos/${id}`),
      staleTime: 30_000,
    }),
}

go deeper

for a junior

Know that keys should come from one factory per feature instead of being typed by hand, and that the feature name goes first and the parameters last.

for a middle

Explain the all, lists, list, details, detail hierarchy and why each level spreads the previous one. Show queryOptions pairing the key with its function and giving typed cache reads.

for a senior

Design the prefixes around the operations you need, such as refreshing every list after a create, and keep list and detail branches apart so one call never over-reaches.

for a principal

Set the factory-plus-queryOptions pattern as a codebase convention, decide who owns key namespaces across features, and review key changes with the same care as API changes.

## Why keys need a single source A TanStack Query **query key** is the identity of a cache entry. The same key is written in many places: the component that reads it, the route that prefetches it, the mutation that updates or invalidates it, and the test that seeds it. If each of those spells the array by hand, a typo, a reordered element or a string where a number belongs quietly creates a second entry. A **query key factory** removes the repetition. It is a single object per feature whose functions return keys, and every other piece of code calls it. ## A hierarchical factory The widely used shape builds each level from the one above it: ```ts export const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const, details: () => [...todoKeys.all, 'detail'] as const, detail: (id: number) => [...todoKeys.details(), id] as const, } ``` `as const` keeps the tuple types precise, so the compiler knows exactly which shape each key has. ## Why general to specific TanStack Query's filters can match keys **by prefix**. A key such as `['todos', 'list']` also matches `['todos', 'list', { status: 'open' }]`. The shape of your keys therefore decides which **groups** a single call can reach: - `todoKeys.all` reaches everything about todos, for example after a bulk import. - `todoKeys.lists()` reaches every list, whatever its filters, for example after a todo is created. - `todoKeys.detail(id)` reaches one item, for example after that item is edited. The rules that make this work: 1. **Most general segment first.** The feature name leads, then the kind of data, then the parameters. 2. **Separate `'list'` from `'detail'`.** Without the segment, `['todos', 5]` and `['todos', { status }]` sit side by side under `['todos']`, and there is no prefix that means "only lists". 3. **Put the filters object last.** It is the most specific part, and an object at the end keeps the earlier segments available as prefixes. How the match itself works, exact against partial and with predicates, is a separate topic. For designing keys, only one point matters: **a group you cannot name with a prefix is a group you cannot target with one call.** ## queryOptions: key and function together A plain key factory still leaves the `queryFn` in each component, where it can drift from the key. v5's **`queryOptions()`** helper closes that gap. At runtime it simply returns what you pass it, but it lets you define the key, the function and shared options in one typed object: - The key and the function are written **side by side**, so every variable the function uses is visibly in the key. - The returned `queryKey` is **tagged with the data type**, so `queryClient.getQueryData(todoQueries.detail(id).queryKey)` returns `Todo | undefined` without a manual generic. - The same object works in `useQuery`, `useSuspenseQuery`, `useQueries`, `queryClient.prefetchQuery` and `queryClient.ensureQueryData`. - A component can still override per-use options, such as `select`, by spreading: `useQuery({ ...todoQueries.list(filters), select })`. - **`infiniteQueryOptions()`** plays the same role for infinite queries. | | Key-only factory | `queryOptions` factory | |---|---|---| | Key typos prevented | yes | yes | | Function tied to its key | no, written at each call site | yes, defined once | | Typed cache reads | needs manual generics | inferred from the tagged key | | Prefix groups | from the key factory | still come from the key factory underneath | The two approaches combine rather than compete. Keep the hierarchical key factory for prefixes, and build `queryOptions` on top of it for the leaves. ## Using the factory everywhere The factory pays off only if every consumer goes through it: - **Routes and loaders** prefetch with the same object the component reads: `queryClient.prefetchQuery(todoQueries.detail(id))`, or `ensureQueryData` when the loader needs the value. - **Mutations** name the group they affect with a factory prefix, such as `todoKeys.lists()`, never with a literal array. - **Tests** seed the cache through the factory, `queryClient.setQueryData(todoKeys.detail(1), todo)`, so a key change breaks the test at compile time rather than letting it pass against a key nobody reads. - **Devtools** show the same structured keys, which makes a stray hand-written key easy to spot. ## Pitfalls - **Mixing** factory keys with hand-written arrays brings the typo problem back. Lint for it in review. - Two features that both use a vague first segment such as `'items'` collide. Name the first segment after the feature. - Putting parameters **before** the kind segment, as in `['todos', { status }, 'list']`, destroys the prefix that would have meant "all lists".

  • Where does the factory live in a feature-based folder structure?
    Next to the feature's API functions, in a `todo-queries.ts` file that the feature's components, routes and mutations import. Colocation keeps the key, the fetch function and their options changing together. Other features import the factory rather than rebuilding the arrays themselves.
  • Why does getQueryData know the data type when you pass a key produced by queryOptions?
    `queryOptions()` returns a `queryKey` whose type carries a tag with the query function's data type. `getQueryData`, `setQueryData` and similar methods read that tag, so they infer `Todo | undefined` without a manual generic. A hand-written array carries no tag, so the result is `unknown` unless you supply the type yourself.
  • Is there a downside to putting the whole filters object in the key?
    Only if the object holds values that do not affect the data, such as UI-only flags or unstable values like a timestamp taken during render. Those create extra entries or a new entry on every render. Keep the object to the parameters the query function actually sends, in serializable form.

saying these in an interview costs you the question

  • Each component should write its own key array next to its useQuery call.
  • Key order does not matter because the library sorts arrays.
  • queryOptions creates a separate cache namespace from useQuery keys.
  • Put the filters first in the key so the most specific data is found fastest.
  • Key factories are only a naming convention with no effect on behaviour.