Modern React Data-Fetching with TypeScript, React-Query, and Zod
While my current stack of choice is based on the fantastic T3 Stack utilizing tRPC, I'm also the lead frontend developer on a JavaScript React app created with Create React App backed by a Rails API. As I port this app to TypeScript, I find myself yearning for the automatic type-safety that tRPC provides.
Luckily, there's a relatively straight-forward solution to the problem of interacting with untyped APIs in TypeScript-based React.
The Building Blocks
The first piece of this puzzle is the incredible React Query. If you're not currently using React Query in your project you should immediately take a step back and ask yourself why. Data fetching has long been a contentious topic in React, and since the arrival of hooks, a lot of developers (myself included) have made the mistake of misusing useEffect
or rolling our own data-fetching libraries. Much like rolling your own authentication system, this can bite you.
The second piece is Zod. While there are a lot of validation libraries out there, Zod is the current frontrunner and you should be using it unless you absolutely, positively have a reason to use something else. Zod's validation and ability to easily infer TypeScript types is going to be key to typing our untyped API.
Putting It Together
The solution is surprisingly concise: Write a Zod validator for the API endpoint, use it to parse the response from the API, and get a typed result in React Query. Let's look at an example of this with a user information API endpoint:
1// Schema for what the API endpoint should be returning 2const UserSchema = z.object({ 3 id: z.number(), 4 email: z.string().email(), 5 displayName: z.string().min(1), 6 role: z.enum(['USER', 'ADMIN']), 7}); 8 9// Optionally, if you want to export this type to pass around 10// elsewhere, you can export it: 11export type User = z.infer<typeof UserSchema>; 12 13// Query the endpoint and parse the response with Zod schema, 14// which will type the response for us 15const query = useQuery(["user"], async () => { 16 const response = await (await fetch('/me')).json(); 17 return UserSchema.parse(response); 18}); 19 20query.data // will be typed
That's it! The resulting query.data
will be typed as either the schema or undefined
(since queries can fail). If the response from the API fails validation, you'll get an error which you can deal with in development or even catch in production with whatever exception logging tool you're using.
While this solution isn't as automatic as tRPC and requires you to write a bit of boilerplate, it does scale well and makes dealing with untyped API endpoints less painful in TypeScript.