The Query Options API
— ReactJs, React Query, TypeScript, JavaScript — 4 min read
- #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
React Query version 5 was released about three months ago, and with it, we got one of the biggest "breaking" changes in the library's history. All of our functions now only get one object passed, instead of multiple arguments. We call this object the Query Options, because it contains all the options you need to create a query:
1- useQuery(2- ['todos'],3- fetchTodos,4- { staleTime: 5000 }5- )6+ useQuery({7+ queryKey: ['todos'],8+ queryFn: fetchTodos,9+ staleTime: 500010+ })
This isn't only true for useQuery
calls, but also for imperative actions like invalidating a query:
1- queryClient.invalidateQueries(['todos'])2+ queryClient.invalidateQueries({ queryKey: ['todos'] })
Now technically, this API isn't new. Most of our functions had overloads, so even in v3, you could already pass an object instead of multiple arguments. It's just that it wasn't really advocated for. All examples, the docs and many blog posts (including this one) used the old API, which is why this was a breaking change for most apps.
So why did we do it?
A better abstraction
First of all, having all those overloads is a chore for maintainers, and it's also not clear for users. Why can I call the same function in multiple ways - is one better than the other? So, streamlining the API, thus making it easier for new starters to understand it, was one goal. "Always pass one object" is as simple and extensible as it gets.
But also, it turns out that one object to rule them all is simply a very good abstraction for when you want to share query options between different functions. I discovered this "by accident" when I wrote the React Query meets React Router article, where want to share query options between prefetching and our useQuery
call. Now usually, you could just write custom hooks as your primary way to re-use queries. But that doesn't work when imperative function calls like prefetching
are involved. So I came up with something, and Alex noted this as a good pattern:
{/ NOTE: The tweet id leads to Alex's now protected X account /}
First time I saw this React Query pattern was @TkDodo's React Router blog post
Brilliant 👏
dev.to/tkdodo/react-q...
Turns out, if all your functions have the same interface - accepting a single object - it makes a lot of sense to abstract that object away into a query definition. Once you have that, you can pass it everywhere:
1const todosQuery = {2 queryKey: ['todos'],3 queryFn: fetchTodos,4 staleTime: 5000,5}6
7// ✅ works8useQuery(todosQuery)9
10// 🤝 sure11queryClient.prefetchQuery(todosQuery)12
13// 🙌 oh yeah14useSuspenseQuery(todosQuery)15
16// 🎉 absolutely17useQueries([{18 queries: [todosQuery]19}]
In hindsight, this pattern just feels beautiful as the main abstraction for queries, and I wanted to apply it everywhere. There was just one problem:
TypeScript
The way TypeScript handles excess properties is quite special. If you inline them, TypeScript will be like: Why are you doing this - it doesn't make any sense, I'll error out:
1useQuery({2 queryKey: ['todos'],3 queryFn: fetchTodos,4 stallTime: 5000,5})
Which is cool, because it catches typos like the one above. But what if you abstract the whole object away into a constant, like our pattern suggests?
1const todosQuery = {2 queryKey: ['todos'],3 queryFn: fetchTodos,4 stallTime: 5000,5}6
7useQuery(todosQuery)
No error. 🙈
TypeScript is quite relaxed in these situations, because at runtime, the "extra" property stallTime
doesn't hurt, and you might want to use that object in a context where the property is required. TypeScript can't know that. And since staleTime
is optional, we are now just not passing it. Of course, this is "valid", but it's not what we'd expect, and it can be a costly mistake to find.
queryOptions
That's why we've introduced a type-safe helper function in v5 called queryOptions
. At runtime, it doesn't do anything:
1export function queryOptions(options) {2 return options3}
But on type level, it's a real powerhouse that not only fixes the above typo issue (see the fixed playground) - it can also help us make other parts of the queryClient
more type-safe:
DataTag
There's one thing about queryClient.getQueryData
and similar functions that has always been a bit annoying in React Query: On type level, they return unknown
. That's because React Query doesn't have an up-front, centralized definition, so when you call queryClient.getQueryData(['todos'])
, there's no way how the library could know what type will be returned.
We are forced to help out ourselves by providing the type parameter to the function call:
1const todos = queryClient.getQueryData<Array<Todo>>(['todos'])2// ^? const todos: Todo[] | undefined
To be clear, this isn't at all safer than just using type assertions, but at least undefined
will be added to the union for us. If we refactor what our fetchTodos
endpoint returns, we won't be notified here of the new type. 😔
But now that we have a function that co-locates queryKey
and queryFn
, we can associate the type of the queryFn
and "tag" our queryKey
with it. Notice what happens when we pass the queryKey
that was created via queryOptions
to getQueryData
:
1const todosQuery = queryOptions({2 queryKey: ['todos'],3 queryFn: fetchTodos,4 staleTime: 5000,5})6
7const todos = queryClient.getQueryData(todosQuery.queryKey)8// ^? const todos: Todo[] | undefined
🤯
This is pure TypeScript magic, contributed by the one and only Mateusz Burzyński. If we look at todosQuery.queryKey
, we can see that it's not only a string array, but it also contains information about what the queryFn
returns:
1(property) queryKey: string[] & {2 [dataTagSymbol]: Todo[];3}
That information will then be read out by getQueryData
(and other functions like setQueryData
, too), to infer the type for us. This brings a whole new level of type-safety to React Query, while at the same time making it easier for us to re-use query options. A huge win in DX. 🚀
Query Factories
So, if you're asking me, I want to use this pattern and the queryOptions
helper everywhere. I would even take it to a point where custom hooks won't be my first choice for abstractions. They seem a bit pointless if all they do is:
1const useTodos = () => useQuery(todosQuery)
There's nothing wrong with calling useQuery
in your component directly, especially if you sometimes want to mix it with useSuspenseQuery
. Of course, if the hook does more, like additional memoization with useMemo
, it's still perfectly fine to add it. But I wouldn't immediately reach for it like I did before.
Additionally, I'm seeing Query Key Factories in a bit of a different light now. I've come to learn that:
The queryKey
defines the dependencies to our queryFn
- everything we use inside it must go into the key. So why define keys in one central place while having the functions far a way from them in our custom hooks?
However, if we combine the two patterns, we're getting the best of all worlds: Type-safety, co-location and great DX. 🚀
An example query factory could look something like this:
1const todoQueries = {2 all: () => ['todos'],3 lists: () => [...todoQueries.all(), 'list'],4 list: (filters: string) =>5 queryOptions({6 queryKey: [...todoQueries.lists(), filters],7 queryFn: () => fetchTodos(filters),8 }),9 details: () => [...todoQueries.all(), 'detail'],10 detail: (id: number) =>11 queryOptions({12 queryKey: [...todoQueries.details(), id],13 queryFn: () => fetchTodo(id),14 staleTime: 5000,15 }),16}
It contains a mix of key-only entries that we can use to build a hierarchy and for query invalidation, as well as full query objects created with the queryOptions
helper.
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. ⬇️