Skip to content
TkDodo's blog

Effective React Query Keys

react, react-query, JavaScript, TypeScript4 min read

keys
Photo by Chunli Ju


Query Keys are a very important core concept in React Query. They are necessary so that the library can internally cache your data correctly and refetch automatically when a dependency to your query changes. Lastly, it will allow you to interact with the Query Cache manually when needed, for example when updating data after a mutation or when you need to manually invalidate some queries.

Let's quickly have a look what these three points mean before I'll show you how I personally organize Query Keys to be able to do these things most effectively.

Caching Data

Internally, the Query Cache is just a JavaScript object, where the keys are serialized Query Keys and the values are your Query Data plus meta information. The keys are hashed it in a deterministic way, so you can use objects as well (on the top level, keys have to be strings or arrays though).

The most important part is that keys need to be unique for your queries. If React Query finds an entry for a key in the cache, it will use it. Please also be aware that you cannot use the same key for useQuery and useInfiniteQuery. There is, after all, only one Query Cache, and you would share the data between these two. That is not good because infinite queries have a fundamentally different structure than "normal" queries.

dont-mix-keys
1useQuery(['todos'], fetchTodos)
2
3// 🚨 this won't work
4useInfiniteQuery(['todos'], fetchInfiniteTodos)
5
6// ✅ choose something else instead
7useInfiniteQuery(['infiniteTodos'], fetchInfiniteTodos)

Automatic Refetching

Queries are declarative.

This is a very important concept that cannot be emphasized enough, and it's also something that might take some time to "click". Most people think about queries, and especially refetching, in an imperative way.

I have a query, it fetches some data. Now I click this button and I want to refetch, but with different parameters. I've seen many attempts that look like this:

imperative-refetch
1function Component() {
2 const { data, refetch } = useQuery(['todos'], fetchTodos)
3
4 // ❓ how do I pass parameters to refetch ❓
5 return <Filters onApply={() => refetch(???)} />
6}

The answer is: You don't.

That's not what refetch is for - it's for refetching with the same parameters.

If you have some state that changes your data, all you need to do is put it in the Query Key, because React Query will trigger a refetch automatically whenever the key changes. So when you want to apply your filters, just change your client state:

query-key-drives-the-query
1function Component() {
2 const [filters, setFilters] = React.useState()
3 const { data } = useQuery(['todos', filters], fetchTodos)
4
5 // ✅ set local state and let it "drive" the query
6 return <Filters onApply={setFilters} />
7}

The re-render triggered by the setFilters update will pass a different Query Key to React Query, which will make it refetch. I have a more in depth example in #1: Practical React Query - Treat the query key like a dependency array.

Manual Interaction

Manual Interactions with the Query Cache are where the structure of your Query Keys are most important. Many of those interaction methods, like invalidateQueries or setQueriesData support Query Filters, which allow you to fuzzily match your Query Keys.

Effective React Query Keys

Please note that these points reflect my personal opinion (as everything on this blog, actually), so don't take it as something that you absolutely must do when working with Query Keys. I have found these strategies to work best when your App becomes more complex, and it also scales quite well. You definitely don't need to do this for a Todo App 😁.

Colocate

If you haven't yet read Maintainability through colocation by Kent C. Dodds, please do. I don't believe that storing all your Query Keys globally in /src/utils/queryKeys.ts will make things better. I keep my Query Keys next to their respective queries, co-located in a feature directory, so something like:

1- src
2 - features
3 - Profile
4 - index.tsx
5 - queries.ts
6 - Todos
7 - index.tsx
8 - queries.ts

The queries file will contain everything React Query related. I usually only export custom hooks, so the actual Query Functions as well as Query Keys will stay local.

Always use Array Keys

Yes, Query Keys can be a string, too, but to keep things unified, I like to always use Arrays. React Query will internally convert them to an Array anyhow, so:

always-use-array-keys
1// 🚨 will be transformed to ['todos'] anyhow
2useQuery('todos')
3// ✅
4useQuery(['todos'])

Structure

Structure your Query Keys from most generic to most specific, with as many levels of granularity as you see fit in between. Here's how I would structure a todos list that allows for filterable lists as well as detail views:

1['todos', 'list', { filters: 'all' }]
2['todos', 'list', { filters: 'done' }]
3['todos', 'detail', 1]
4['todos', 'detail', 2]

With that structure, I can invalidate everything todo related with ['todos'], all the lists or all the details as well as target one specific list if I know the exact key. Updates from Mutation Responses become a lot more flexible with this, because you can target all lists if necessary:

updates-from-mutation-responses
1function useUpdateTitle() {
2 return useMutation(updateTitle, {
3 onSuccess: (newTodo) => {
4 // ✅ update the todo detail
5 queryClient.setQueryData(['todos', 'detail', newTodo.id], newTodo)
6
7 // ✅ update all the lists that contain this todo
8 queryClient.setQueriesData(['todos', 'list'], (previous) =>
9 previous.map((todo) => (todo.id === newTodo.id ? newtodo : todo))
10 )
11 },
12 })
13}

This might not work if the structure of lists and details differ a lot, so alternatively, you can also of course just invalidate all the list instead:

invalidate-all-lists
1function useUpdateTitle() {
2 return useMutation(updateTitle, {
3 onSuccess: (newTodo) => {
4 queryClient.setQueryData(['todos', 'detail', newTodo.id], newTodo)
5
6 // ✅ just invalidate all lists
7 queryClient.invalidateQueries(['todos', 'list'])
8 },
9 })
10}

If you know which list you are currently on, e.g. by reading the filters from the url, and can therefore construct the exact Query Key, you can also combine this two methods and call setQueryData on your list and invalidate all others:

combine
1function useUpdateTitle() {
2 // imagine a custom hook that returns the current filters,
3 // stored in the url
4 const { filters } = useFilterParams()
5
6 return useMutation(updateTitle, {
7 onSuccess: (newTodo) => {
8 queryClient.setQueryData(['todos', 'detail', newTodo.id], newTodo)
9
10 // ✅ update the list we are currently on instantly
11 queryClient.setQueryData(['todos', 'list', { filters }], (previous) =>
12 previous.map((todo) => (todo.id === newTodo.id ? newtodo : todo))
13 )
14
15 // 🥳 invalidate all lists, but don't refetch the active one
16 queryClient.invalidateQueries({
17 queryKey: ['todos', 'list'],
18 refetchActive: false,
19 })
20 },
21 })
22}

Use Query Key factories

In the examples above, you can see that I've been manually declaring the Query Keys a lot. This is not only error prone, but also makes it harder to change in the future, for example, if you find out that you'd like to add another level of granularity to your keys.

That's why I recommend one Query Key factory per feature. It's just a simple object with entries and functions that will produce query keys, which can you can then use in your custom hooks. For the above example structure, it would look something like this:

query-key-factory
1const todoKeys = {
2 all: ['todos'] as const,
3 lists: () => [...todoKeys.all, 'list'] as const,
4 list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
5 details: () => [...todoKeys.all, 'detail'] as const,
6 detail: (id: number) => [...todoKeys.details(), id] as const,
7}

This gives me a lot of flexibility, as each level builds on top of each other, but is still accessible independently:

examples
1// 🕺 remove everything related to the todos feature
2queryClient.removeQueries(todoKeys.all)
3
4// 🚀 invalidate all lists
5queryClient.invalidateQueries(todoKeys.lists())
6
7// 🙌 prefetch a single todo
8queryClient.prefetchQueries(todoKeys.detail(id), () => fetchTodo(id))

That's it for today. Feel free to reach out to me on twitter if you have any questions, or just leave a comment below ⬇️