Blog Post
Why I Like a React Query Key Factory
A small query key factory made my TanStack Query cache updates and invalidations much less annoying.
Photo by Filip Szalbot
I got tired of rebuilding query keys from memory.
Not because they were hard. Because they were just annoying enough to get wrong once in a while, which is the worst category of problem.
I ended up following the pattern from TkDodo’s post on effective React Query keys. I use a small key factory per feature now, and I like it more than I expected.
The Problem
Without some structure, query keys tend to turn into scattered string arrays:
useQuery({ queryKey: ["todos", "list", filters], queryFn: () => fetchTodos(filters),})
queryClient.invalidateQueries({ queryKey: ["todos", "list"],})
queryClient.setQueryData(["todos", "detail", id], nextTodo)This works until it does not.
The issue is not React Query. The issue is that I now have the same key shape repeated across hooks, mutations, and cache updates. One typo or one slightly different structure and the cache quietly stops doing what I thought it would do.
That is a very efficient way to create a bug I will only notice later.
The Factory
I keep the keys next to the feature and build them from generic to specific:
export const todoKeys = { all: ["todos"] as const, lists: () => [...todoKeys.all, "list"] as const, list: (filters: string) => [...todoKeys.lists(), { filters }] as const, details: () => [...todoKeys.all, "detail"] as const, detail: (id: string) => [...todoKeys.details(), id] as const,}Then usage becomes boring in a good way:
useQuery({ queryKey: todoKeys.list(filter), queryFn: () => fetchTodos(filter),})
useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id),})
queryClient.invalidateQueries({ queryKey: todoKeys.lists(),})
queryClient.setQueryData(todoKeys.detail(todo.id), todo)Everything comes from one place. I do not need to remember whether I used "detail" or "details", or whether filters were a string, an object, or a vague feeling.
Why I Kept It
The main benefit is not “clean architecture”. It is lower friction.
When I add a mutation, I already know which keys I can invalidate. When I update cached data, I do not rebuild the path by hand. When I rename part of the structure, TypeScript helps instead of watching silently.
It also matches the part of TkDodo’s article that mattered most to me: query keys are not labels. They are part of how the cache works. If they drive caching, refetching, and manual updates, they deserve slightly more respect than random inline arrays.
What I Actually Like About This Pattern
I do not keep one giant global queryKeys.ts. That file always feels tidy for about ten minutes.
I keep a small factory per feature, colocated with the queries that use it. That keeps the mental model local:
- one feature
- one key shape
- one obvious way to address list and detail data
It is not a revolutionary abstraction. It just removes repetition from the exact place where repetition becomes expensive.
If you already use TanStack Query and your cache code feels a bit too improvised, a tiny key factory is worth it. It made mine much harder to accidentally outsmart.