Effective React Query Keys
โ ReactJs, React Query, JavaScript, TypeScript โ 4 min read
Last Update: 2022-04-23
- #1: Practical React Query
- #2: React Query Data Transformations
- #3: React Query Render Optimizations
- #4: Status Checks in React Query
- #5: Testing React Query
- #6: React Query and TypeScript
- #7: Using WebSockets with React Query
- #8: Effective React Query Keys
- #8a: Leveraging the Query Function Context
- #9: Placeholder and Initial Data in React Query
- #10: React Query as a State Manager
- #11: React Query Error Handling
- #12: Mastering Mutations in React Query
- #13: Offline React Query
- #14: React Query and Forms
- #15: React Query FAQs
- #16: React Query meets React Router
- #17: Seeding the Query Cache
- #18: Inside React Query
- #19: Type-safe React Query
- #20: You Might Not Need React Query
- #21: Thinking in React Query
- #22: React Query and React Context
- #23: Why You Want React Query
- #24: The Query Options API
- #25: Automatic Query Invalidation after Mutations
- #26: How Infinite Queries work
- #27: React Query API Design - Lessons Learned
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 work7useInfiniteQuery({8 queryKey: ['todos'],9 queryFn: fetchInfiniteTodos,10})11
12// โ
choose something else instead13useInfiniteQuery({14 queryKey: ['infiniteTodos'],15 queryFn: fetchInfiniteTodos,16})
Automatic Refetching
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:
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:
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 query9 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- src2 - features3 - Profile4 - index.tsx5 - queries.ts6 - Todos7 - index.tsx8 - 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:
1// ๐จ will be transformed to ['todos'] anyhow2useQuery({ 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:
1function useUpdateTitle() {2 return useMutation({3 mutationFn: updateTitle,4 onSuccess: (newTodo) => {5 // โ
update the todo detail6 queryClient.setQueryData(7 ['todos', 'detail', newTodo.id],8 newTodo9 )10
11 // โ
update all the lists that contain this todo12 queryClient.setQueriesData(['todos', 'list'], (previous) =>13 previous.map((todo) =>14 todo.id === newTodo.id ? newtodo : todo15 )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:
1function useUpdateTitle() {2 return useMutation({3 mutationFn: updateTitle,4 onSuccess: (newTodo) => {5 queryClient.setQueryData(6 ['todos', 'detail', newTodo.id],7 newTodo8 )9
10 // โ
just invalidate all the lists11 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:
1function useUpdateTitle() {2 // imagine a custom hook that returns3 // the current filters, stored in the url4 const { filters } = useFilterParams()5
6 return useMutation({7 mutationFn: updateTitle,8 onSuccess: (newTodo) => {9 queryClient.setQueryData(10 ['todos', 'detail', newTodo.id],11 newTodo12 )13
14 // โ
update the list we are currently on15 queryClient.setQueryData(16 ['todos', 'list', { filters }],17 (previous) =>18 previous.map((todo) =>19 todo.id === newTodo.id ? newtodo : todo20 )21 )22
23 // ๐ฅณ invalidate all the lists,24 // but don't refetch the active one25 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:
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:
1// ๐บ remove everything related2// to the todos feature3queryClient.removeQueries({4 queryKey: todoKeys.all5})6
7// ๐ invalidate all the lists8queryClient.invalidateQueries({9 queryKey: todoKeys.lists()10})11
12// ๐ prefetch a single todo13queryClient.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. โฌ๏ธ