Skip to content
TkDodo's blog
BlueskyGithub

Effective React Query Keys

โ€” ReactJs, React Query, JavaScript, TypeScript โ€” 4 min read

keys
Photo by Chunli Ju

Last Update: 2022-04-23

  • ํ•œ๊ตญ์–ด
  • ๆ—ฅๆœฌ่ชž
  • ็ฎ€ไฝ“ไธญๆ–‡
  • Espaรฑol
  • Add translation

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 at what these three points mean before showing you how I personally organize Query Keys to be able to do these things more 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 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.

1useQuery({
2 queryKey: ['todos'],
3 queryFn: fetchTodos,
4})
5
6// ๐Ÿšจ this won't work
7useInfiniteQuery({
8 queryKey: ['todos'],
9 queryFn: fetchInfiniteTodos,
10})
11
12// โœ… choose something else instead
13useInfiniteQuery({
14 queryKey: ['infiniteTodos'],
15 queryFn: fetchInfiniteTodos,
16})

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({
3 queryKey: ['todos'],
4 queryFn: fetchTodos,
5 })
6
7 // โ“ how do I pass parameters to refetch โ“
8 return <Filters onApply={() => refetch(???)} />
9}

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 to 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({
4 queryKey: ['todos', filters],
5 queryFn: () => fetchTodos(filters),
6 })
7
8 // โœ… set local state and let it drive the query
9 return <Filters onApply={setFilters} />
10}

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 is 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 they also scale 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({ queryKey: 'todos' })
3// โœ…
4useQuery({ queryKey: ['todos'] })

โ€‹Update: With React Query v4, all keys need to be Arrays.

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({
3 mutationFn: updateTitle,
4 onSuccess: (newTodo) => {
5 // โœ… update the todo detail
6 queryClient.setQueryData(
7 ['todos', 'detail', newTodo.id],
8 newTodo
9 )
10
11 // โœ… update all the lists that contain this todo
12 queryClient.setQueriesData(['todos', 'list'], (previous) =>
13 previous.map((todo) =>
14 todo.id === newTodo.id ? newtodo : todo
15 )
16 )
17 },
18 })
19}

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 lists instead:

invalidate-all-lists
1function useUpdateTitle() {
2 return useMutation({
3 mutationFn: updateTitle,
4 onSuccess: (newTodo) => {
5 queryClient.setQueryData(
6 ['todos', 'detail', newTodo.id],
7 newTodo
8 )
9
10 // โœ… just invalidate all the lists
11 queryClient.invalidateQueries({
12 queryKey: ['todos', 'list']
13 })
14 },
15 })
16}

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 the others:

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

โ€‹Update: In v4, refetchActive has been replaced with refetchType. In the above example, that would be refetchType: 'none', because we don't want to refetch anything.

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 it also makes changes harder 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 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 another, but is still independently accessible:

examples
1// ๐Ÿ•บ remove everything related
2// to the todos feature
3queryClient.removeQueries({
4 queryKey: todoKeys.all
5})
6
7// ๐Ÿš€ invalidate all the lists
8queryClient.invalidateQueries({
9 queryKey: todoKeys.lists()
10})
11
12// ๐Ÿ™Œ prefetch a single todo
13queryClient.prefetchQueries({
14 queryKey: todoKeys.detail(id),
15 queryFn: () => fetchTodo(id),
16})

That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. โฌ‡๏ธ

ยฉ 2024 by TkDodo's blog. All rights reserved.
Theme by LekoArts