Building a typesafe multi-form action handler in Remix Series - Remix validated form and Zod

Building a typesafe multi-form action handler in Remix Series - Remix validated form and Zod
Photo by ANIRUDH / Unsplash

In this series, I build a small Todo application. To begin, I use a combination of Remix, TypeScript, Tailwind CSS, and React. It'll be functional but fragile to change. To address the fragility, I introduce and use Remix Validated Form, a React form library, Zod, a TypeScript-first schema validation framework, and a few helpers to glue it all together. Each post in this series will build upon the last by solving more of the problem and ends with a robust typesafe solution.

Posts in this series

  1. The problem (read this first)
  2. Remix validated form & Zod (you are here)
  3. Hooks, Proxy and Dispatcher to the rescue

If you haven't read part 1, The problem, I highly recommend you start there.

The source code for this part is on GitHub, or try it out on Stackblitz


In the first post of the series, The problem, you may have noticed that the Todo application code was missing client-side validation, and the server-side validation was a little half-baked and manual. I could have written client-side validation code, but it would be similar, if not exactly the same, as the server-side validation. I'm already using Remix and React to do a lot of the heavy lifting, so why not use something to help with form posts and the validation for both client-side and server-side?

Introducing Remix Validated Form and Zod

To build upon what I have so far, I add the Remix Validated Form and Zod packages to the project. Remix Validated Form and Zod allow me to easily define forms, provide client-side and server-side validation, and nicely take the post data and return it as a TypeScript  typed plain old JS object.

Updating the Todo application

In part 1, I made a simple Todo application. Now that I've added Remix Validated Form and Zod, I'll start refactoring the solution to use them.


Using Zod, I define what a valid shape for post data is. As the shape of the data changes depending on the value in the _action property, I use the property to form the discriminator when defining a discriminated union.

My Zod code looks like the following.

const validator = withZod(
  z.discriminatedUnion('_action', [
      _action: z.literal('reset'),
      _action: z.literal('upsert'),
      description: z.string().min(2).max(50),
      id: zfd.numeric(z.number().optional()),
      _action: z.literal('delete'),
      id: zfd.numeric(),
      _action: z.literal('complete'),
      id: zfd.numeric(),

As you can see, I define what it is to be valid for the actions reset, upsert, delete and complete.

Refactoring the action handler

Now that I've told Zod what the valid shape of post data looks like, I can refactor the action handler to remove any manual validation I added, allowing me to replace it all with the following.

  const formData = await data.request.formData()
  const result = await validator.validate(formData)

  if (result.error) return validationError(result.error)

Pretty neat, right?

As Zod validates incoming data and knows a valid shape, it can also provide type information for the post data, meaning I can now access it safely. For example:

// console.log( ❌ Error
// console.log( ❌ Error

if ( === 'upsert') {
  console.log( // ✅ No error
  console.log( // ✅ No error

How? It's because I'm using a discriminated union, and TypeScript knows which properties can be accessed safely as I narrow the type based on the discriminated value in _action.

With all this in mind, I replace the action handler with a new version using validation and type information provided by Zod.

export const action = async (data: DataFunctionArgs) => {
  // Simulate network latency
  await randomDelayBetween(250, 1000)

  const formData = await data.request.formData()
  const result = await validator.validate(formData)

  if (result.error) return validationError(result.error)

  switch ( {
    case 'reset': {
      await db.populateSample()
    case 'delete': {
      db.patch(Number(, { deleted: true })
    case 'complete': {
      db.patch(Number(, { completed: true })
    case 'upsert': {
      const isEdit = !isNaN(Number(
      if (isEdit) {
        db.patch(Number(, {
      } else {
        db.append({ description: })
  return null

Remix validated form

With the server-side validation out of the way, I can introduce client-side validation. To do this, I first add a few helper components for inputs, as it'll be cleaner to use controlled inputs (read more about this here) to have better control and form a consistent way of showing an invalid state.

I won't deep-dive into how these work, as you can view the source here, but the primary takeaway is that I can react to an invalid input by altering elements and/or styling. For example:

const { error } = useField(name)

// Add elements to show the validation error
{error && (
    className={cn('px-3 pt-3 pb-2 -mt-1', 'bg-error text-sm rounded-b')}

// Add styling to show the validation error
  className={cn(className, error && 'border-raspberry')} />

Next, I swap the Remix <Form /> component over to <ValidatedForm /> the Remix Validated Form component, and supply it with the Zod validator I defined earlier. Additionally, I swap the form inputs over their <Validated* /> equivalent. With all this put together, my upsert form, as an example, now looks like this.

  onSubmit={() => {
  <ValidatedHiddenInput name='id' value={edit?.id.toString()} />
  <div className='mt-2 py-3 px-4 grid grid-flow-col auto-cols-[1fr_200px] gap-2 items-start'>
      className='p-2 border'
      label='To-do description'
      placeholder='Todo description'
      {edit ? 'Edit' : 'Add'}

And if I attempt to add a todo without a description or a description that isn't at least two or more characters long, I get client-side validation 🎉


In my next post, I introduce a few helpers to remove all the fragile strings which aren't typesafe or error at runtime when using an incorrect value.

You can view the source code for this part on GitHub, or try it out on Stackblitz.