Effective React Query Keys
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
- #28: React Query - The Bad Parts
- #29: Concurrent Optimistic Updates in React Query
- #30: React Query Selectors, Supercharged
Query Keys (opens in a new window) 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.
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 (opens in a new window), 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.
useQuery({ queryKey: ["todos"], queryFn: fetchTodos,});
// 🚨 this won't workuseInfiniteQuery({ queryKey: ["todos"], queryFn: fetchInfiniteTodos,});
// ✅ choose something else insteaduseInfiniteQuery({ queryKey: ["infiniteTodos"], queryFn: fetchInfiniteTodos,});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:
function Component() { const { data, refetch } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, })
// ❓ how do I pass parameters to refetch ❓ return <Filters onApply={() => refetch(???)} />}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:
function Component() { const [filters, setFilters] = React.useState() const { data } = useQuery({ queryKey: ['todos', filters], queryFn: () => fetchTodos(filters), })
// ✅ set local state and let it drive the query return <Filters onApply={setFilters} />}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 Interactions with the Query Cache are where the structure of your Query Keys is most important. Many of those interaction methods, like invalidateQueries (opens in a new window) or setQueriesData (opens in a new window) support Query Filters (opens in a new window), which allow you to fuzzily match your 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 😁.
If you haven’t yet read Maintainability through colocation (opens in a new window) by Kent C. Dodds (opens in a new window), 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:
- src - features - Profile - index.tsx - queries.ts - Todos - index.tsx - queries.tsThe 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.
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:
// 🚨 will be transformed to ['todos'] anyhowuseQuery({ queryKey: 'todos' })// ✅useQuery({ queryKey: ['todos'] })Update: With React Query v4, all keys need to be Arrays.
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:
['todos', 'list', { filters: 'all' }]['todos', 'list', { filters: 'done' }]['todos', 'detail', 1]['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
(opens in a new window) become a lot more flexible with this, because you can target all lists if necessary:
function useUpdateTitle() { return useMutation({ mutationFn: updateTitle, onSuccess: (newTodo) => { // ✅ update the todo detail queryClient.setQueryData( ['todos', 'detail', newTodo.id], newTodo )
// ✅ update all the lists that contain this todo queryClient.setQueriesData(['todos', 'list'], (previous) => previous.map((todo) => todo.id === newTodo.id ? newTodo : todo ) ) }, })}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:
function useUpdateTitle() { return useMutation({ mutationFn: updateTitle, onSuccess: (newTodo) => { queryClient.setQueryData( ['todos', 'detail', newTodo.id], newTodo )
// ✅ just invalidate all the lists queryClient.invalidateQueries({ queryKey: ['todos', 'list'] }) }, })}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:
function useUpdateTitle() { // imagine a custom hook that returns // the current filters, stored in the url const { filters } = useFilterParams()
return useMutation({ mutationFn: updateTitle, onSuccess: (newTodo) => { queryClient.setQueryData( ['todos', 'detail', newTodo.id], newTodo )
// ✅ update the list we are currently on queryClient.setQueryData( ['todos', 'list', { filters }], (previous) => previous.map((todo) => todo.id === newTodo.id ? newtodo : todo ) )
// 🥳 invalidate all the lists, // but don't refetch the active one queryClient.invalidateQueries({ queryKey: ['todos', 'list'], refetchActive: false, }) }, })}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.
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:
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: number) => [...todoKeys.details(), id] as const,}This gives me a lot of flexibility, as each level builds on top of another, but is still independently accessible:
// 🕺 remove everything related// to the todos featurequeryClient.removeQueries({ queryKey: todoKeys.all})
// 🚀 invalidate all the listsqueryClient.invalidateQueries({ queryKey: todoKeys.lists()})
// 🙌 prefetch a single todoqueryClient.prefetchQueries({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id),})That’s it for today. Feel free to reach out to me on bluesky (opens in a new window) if you have any questions, or just leave a comment below. ⬇️