Type-safe React Query
— ReactJs, React Query, TypeScript, JavaScript — 5 min read
Last Update: 2023-10-21
- #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
I think we can all agree that using TypeScript is a good idea. Who doesn't like type-safety? It's a great way to catch bugs early on, and it allows us to offload some complexity of our apps to the type definitions so that we don't have to keep them in our heads forever.
The level of type-safety can drastically vary from project to project. After all, every valid JavaScript code can be valid TypeScript code - depending on the TS settings. And there is also a big difference between "having types" and "being type-safe".
To truly leverage the power of TypeScript, there is one thing that you need above all:
Trust
We need to be able to trust our type definitions. If we don't, our types become a mere suggestion - we can't rely on them to be accurate. So we go above and beyond to make sure we can trust them:
- We enable the strictest of TypeScript settings.
- We add typescript-eslint to forbid the
any
type as well asts-ignore
. - We point out all type assertions in code reviews.
And still - we are probably lying. A LOT. Even if we adhere to all the above things.
Generics
Generics are essential in TypeScript. As soon as you want to implement something remotely complex, you will have to reach for them - especially when you're writing a reusable library.
However, as a user of a library, you ideally shouldn't need to care about their Generics. They are an implementation detail. So whenever you provide a generic "manually" to a function via the angle brackets, it's kinda bad for one of two reasons:
About angle brackets
Angle brackets makes your code look "more complex" than it has to be. As an example, let's look at how useQuery
is often written:
1type Todo = { id: number; name: string; done: boolean }2
3const fetchTodo = async (id: number) => {4 const response = await axios.get(`/todos/${id}`)5 return response.data6}7
8const query = useQuery<Todo>({9 queryKey: ['todos', id],10 queryFn: fetchTodo,11})12
13query.data14// ^?(property) data: Todo | undefined
The main problem here is that useQuery
has four generics. By providing only one of them manually, the other three fall back to their default values. You can read about why that's bad in #6: React Query and TypeScript.
Just to be on the same page - axios.get
returns any
(just like fetch
would, but ky does this slightly better by giving us unknown
back per default). It doesn't know what the /todos/id
endpoint will return. And because we don't want our data
property to be any
as well, we have to "override" the inferred generic by providing it manually. Or do we?
The better way is to type the fetchTodo
function itself:
1type Todo = { id: number; name: string; done: boolean }2
3// ✅ typing the return value of fetchTodo4const fetchTodo = async (id: number): Promise<Todo> => {5 const response = await axios.get(`/todos/${id}`)6 return response.data7}8
9// ✅ no generics on useQuery10const query = useQuery({11 queryKey: ['todos', id],12 queryFn: () => fetchTodo(id),13})14
15// 🙌 types are still properly inferred16query.data17// ^?(property) data: Todo | undefined
Now with this, React Query can properly infer what data will be from the result of the queryFn
. No need for manual generics. If the input to useQuery
is sufficiently typed, you will not have to add angle brackets to it. 🎉
Lying angle brackets
Alternatively, we can also tell our data fetching layer, in this case axios
, what the expected type is by providing the Generics via angle brackets there:
1const fetchTodo = async (id: number) => {2 const response = await axios.get<Todo>(`/todos/${id}`)3 return response.data4}
Now we don't even have to type the fetchTodo
function if we don't want to because type inference will again work for us here. Those generics are not unnecessary per se, but they are a lie because they violate the golden rule of Generics.
The golden rule of Generics
I learned this rule from @danvdk's great book Effective TypeScript. It basically states:
The so called "return-only" generics are nothing more than a type assertion in disguise. The (slightly simplified) type signature for axios.get
reads:
1function get<T = any>(url: string): Promise<{ data: T, status: number}>
The Type T
only appears in one place - the return type. So it's a lie!
We could've just as well written:
1const fetchTodo = async (id: number) => {2 const response = await axios.get(`/todos/${id}`)3 return response.data as Todo4}
At least this type assertion (as Todo
) is explicit and not hidden. It shows that we are bypassing the compiler, that we are getting something unsafe and trying to turn it into something we can trust.
Trust again
And now we are back to trust. How can we trust that what we're getting over the wire is in fact of a certain type? We cannot, and maybe that's okay.
I used to refer to this situation as a "trusted boundary". We have to trust that what the backend returns is what we have agreed upon. If it's not, this isn't our fault - it's the fault of the backend team.
Of course, the customer doesn't care. All they see is "cannot read property name of undefined" or something similar. Frontend devs will be called into the escalation, and it will take us quite a bit of time to actually figure out that we're not getting the right shape of data over the wire, because the error will appear in a completely different place.
So is there something that we can do to give us trust?
zod
zod is a beautiful validation library that lets you define a schema you can validate against at runtime. On top of that, it infers the type of the validated data directly from the schema.
This basically means that instead of writing a type definition and then asserting that something is that type, we write a schema and validate that the input conforms to that schema - at which point it becomes that type.
I first heard about zod when working with forms. It makes total sense to validate user input. As a nice side effect, the input will also be typed correctly after the validation. But we can not only validate user input - we can validate anything. Url params for example. Or network responses...
validation in the queryFn
1import { z } from 'zod'2
3// 👀 define the schema4const todoSchema = z.object({5 id: z.number(),6 name: z.string(),7 done: z.boolean(),8})9
10const fetchTodo = async (id: number) => {11 const response = await axios.get(`/todos/${id}`)12 // 🎉 parse against the schema13 return todoSchema.parse(response.data)14}15
16const query = useQuery({17 queryKey: ['todos', id],18 queryFn: () => fetchTodo(id),19})
This isn't even more code than before. We've basically exchanged two things:
- the manual type definition of the
Todo
type with thetodoSchema
definition. - the type assertion with the schema parsing.
This plays so well together with React Query because parse
throws a descriptive Error
if something went wrong, which will make React Query go into error
state - just as if the network call itself failed. And from the client perspective - it did fail, because it didn't return the expected structure. Now we have an error
state that we need to handle anyway, and there will be no surprises for our users.
It also goes nicely with another guideline of mine:
Apart from id: number
, there isn't a single thing that differentiates this TS code from JS. There is no added TypeScript complexity - we just get the benefits of type-safety. Type inference "flows" through our code like a hot knife through butter. 🤤
Tradeoffs
Schema parsing is a great concept to be aware of, but it's not for free. For starters, your schemas should be as resilient as you want them to be. If it doesn't matter that an optional property is null
or undefined
at runtime, you might create a miserable user experience if you fail the query because of something like that. So design your schemas resiliently.
Also, parsing does come with an overhead, as data must be analyzed at runtime to see if it fits the required structure. So it might not make sense to apply this technique everywhere.
What about getQueryData
You might have noticed that queryClient.getQueryData
suffers from the same problem: It contains a return-only generic, and it will default to unknown
if you don't provide it.
1const todo = queryClient.getQueryData(['todos', 1])2// ^? const todo: unknown3
4const todo = queryClient.getQueryData<Todo>(['todos', 1])5// ^? const todo: Todo | undefined
Since React Query cannot know what you put into the QueryCache
(as there is no up-front defined overall schema), this is the best we can do. Of course, you can also parse the result of getQueryData
with a schema, but this isn't really necessary if you've validated the cached data before. Also, direct interactions with the QueryCache
should be done sparingly.
Tools on top of React Query, like react-query-kit, do a great job at alleviating the pain, but they can only go so far and basically hide the lie a bit more for you.
End-to-end type-safety
While there isn't a lot more that React Query can do for us in this regard, there are other tools that can. If you are in control over both your frontend and backend, and if they even live in the same monorepo together, consider using tools like tRPC or zodios. They both build on top of React Query for the client-side data fetching solution, but they both have what it takes to become truly type-safe: an upfront API / router definition.
With that, types on the frontend can be inferred from whatever the backend produces - without a chance of being wrong. They also both use zod
for defining the schema (tRPC is validation library agnostic, but zod
is the most popular), so learning how to work with zod
could definitely go on your list to learn for 2023. 🎊
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. ⬇️