Validating Remix Form Data Using Zod and TypeScript in Action Functions

Joel Hooks
author
Joel Hooks
abstract swirling lights

If you love TypeScript it's likely in part because of the safety that using an excellent type system will give you, and once you start using types and feel that level of comfort, you'll start wanting to use it everywhere.

Remix actions allow you to use standard web APIs to handle form data and capture user input in the context of your web server while coding in the context of the pages and components that the user is interacting with.

Using the schema-validation library Zod let's you combine the ergonomics of Remix actions, with standard web APIs, and the safety of TypeScript.

It's great!

With your Remix apps, when you POST a form, your ActionFunction has formData available on the request.

This is actually kind of a radical approach in the world of modern JavaScript frameworks. The FormData that remix is posting with the request is the standard web API for form data that you can look up on MDN. Instead of giving you some new API to learn, Remix strives to utilize standard APIs.

Of course, form data isn't typed, so how can we bring TypeScript into the mix and create types from our form data?

Say hello to Zod.

TypeScript-first schema validation with static type inference

This means that you define a schema, feed any plain old JavaScript object into it, and Zod will validate the data against the schema and return a typed object that you can use. Here's what it looks like in the context of a Remix route:

import type {ActionFunction} from '@remix-run/node'
import {redirect} from '@remix-run/node'
import {z} from 'zod'
import {Form} from '@remix-run/react'
export const action: ActionFunction = async ({request}) => {
const formPayload = Object.fromEntries(await request.formData())
const subscriberSchema = z.object({
name: z.string(),
email: z.string().email(),
})
try {
const newSubscriber = subscriberSchema.parse(formPayload)
// subscribe them to a newsletter or whatever
return redirect(`/confirmed`)
} catch (error) {
console.error(`form not submitted ${error}`)
return redirect(`/?error=form-not-submitted`)
}
}
export default function Index() {
return (
<Form method="post">
<label>
Name: <input name="name" type="text" />
</label>
<label>
Email: <input name="email" type="email" />
</label>
<button type="submit">Subscribe</button>
</Form>
)
}

As is normally the case in Remix, our page exports a component. In this case the component that is exported is named Index. That component is relatively simple and exports a Form component with two labeled inputs and a submit button. The Form uses the post method, but you'll notice that it doesn't have an action attribute.

From the docs:

Most of the time you can omit this prop. Forms without an action prop (<Form method="post">) will automatically post to the same route within which they are rendered. This makes collocating your component, your data reads, and your data writes a snap.

There are cases where you might want to post to another route than the one you are on, but Remix by default will assume the ActionFunction is in the same route as the form.

When the user hits Submit the form will post to the ActionFunction. This gives you access to the request which contains the form data:

const formPayload = Object.fromEntries(await request.formData())

FormData isn't a plain object and has a low level API for interacting with the data that is good to understand, but the above uses Object.fromEntries to create a plain-old JS object from the form data which is what you want in this case.

Here's where Zod comes in:

const subscriberSchema = z.object({
name: z.string(),
email: z.string().email(),
})

With Zod, you build a schema based on the shape of the data that you expect. There is a deep API for building schemas, and this is just the tip of the iceberg, but it's already super handy.

try {
const newSubscriber = subscriberSchema.parse(formPayload)
// subscribe them to a newsletter or whatever
return redirect(`/confirmed`)
}

Using subscriberSchema.parse and passing it our JavaScript object validates that the object conforms to the schema and gives us a type inferred object that we can have more confidence in using. Once we've validated the new subscriber, we can save their information and do other work before redirecting them to another route to display a confirmation.

You could also return JSON and access that data on the same page with useActionData if that better suited your needs.

If the data doesn't pass the validation Zod will throw an error, so you'll want to handle that as well:

try {
const newSubscriber = subscriberSchema.parse(formPayload)
// subscribe them to a newsletter or whatever
return redirect(`/confirmed`)
} catch (error) {
console.error(`form not submitted ${error}`)
return redirect(`/?error=form-not-submitted`)
}

In this case we log the error to our (server) console and update the current route with an error parameter. This isn't very robust, and I'd recommend consider using useActionData with an error object to give the user more specific feedback about what went wrong.

import type {ActionFunction} from '@remix-run/node'
import {redirect, json} from '@remix-run/node'
import {z} from 'zod'
import {Form} from '@remix-run/react'
export const action: ActionFunction = async ({request}) => {
const formPayload = Object.fromEntries(await request.formData())
const subscriberSchema = z.object({
name: z.string(),
email: z.string().email(),
})
try {
const newSubscriber = subscriberSchema.parse(formPayload)
// subscribe them to a newsletter or whatever
return redirect(`/confirmed`)
} catch (error) {
console.error(`form not submitted ${error}`)
return json({ error });
}
}
export default function Index() {
const data = useActionData();
return (
<Form method="post">
<label>
Name: <input name="name" type="text" />
</label>
<label>
Email: <input name="email" type="email" />
</label>
{data && data.error && (<p>Your form had an error!</p>)}
<button type="submit">Subscribe</button>
</Form>
)
}

The error object Zod produces contains an array called issues that can be used to be more specific and thorough in your Zod error handling.

This is just the beginning of what Zod and Remix action functions can do!

If you'd like to learn more about Remix, check out this free course from Kent C. Dodds.

Matt Pocock's interactive video tutorial covering the basics of Zod is a great resource if you'd like to learn more about Zod!