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

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.

πŸ“£
This was updated on 11/2024. It includes the latest versions of Remix, TypeScript, Tailwind CSS, React, Remix Validated Form, and Zod.

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

Recap

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.

Zod

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', [
    z.object({
      _action: z.literal('reset'),
    }),
    z.object({
      _action: z.literal('upsert'),
      description: z.string().min(2).max(50),
      id: zfd.numeric(z.number().optional()),
    }),
    z.object({
      _action: z.literal('delete'),
      id: zfd.numeric(),
    }),
    z.object({
      _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(result._action)
// console.log(result.data.id) ❌ Error
// console.log(result.data.description) ❌ Error

if (result.data._action === 'upsert') {
  console.log(result.data.id) // βœ… No error
  console.log(result.data.description) // βœ… 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.

Considering all this, I replaced the action handler with a new version that uses the validation and type information provided by Zod.

export const action = async ({ request }: ActionFunctionArgs) => {
  // Simulate network latency
  await randomDelayBetween(50, 350)

  const formData = await request.formData()
  const { data, error } = await validator.validate(formData)
  if (error) return validationError(error)

  switch (data._action) {
    case 'reset': {
      db.populateSample()
      break
    }
    case 'delete': {
      db.patch(Number(data.id), { deleted: true })
      break
    }
    case 'complete': {
      db.patch(Number(data.id), { completed: true })
      break
    }
    case 'upsert': {
      if (data.description === 'test') return 'Test is not allowed'
      const isEdit = !isNaN(Number(data.id))
      if (isEdit) {
        db.patch(Number(data.id), {
          description: data.description,
        })
      } else {
        db.append({ description: data.description })
      }
      break
    }
  }
  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 && (
  <div
    className={cn('px-3 pt-3 pb-2 -mt-1', 'bg-error text-sm rounded-b')}
  >
    {error}
  </div>
)}

// Add styling to show the validation error
<input
  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.

<ValidatedForm
  formRef={formRef}
  validator={validator}
  onSubmit={() => {
    setTimeout(clearEdit)
  }}
  resetAfterSubmit={true}
  method='post'
>
  <ValidatedHiddenInput name='id' value={editTodo?.id.toString()} />
  <div className='mt-2 py-3 px-4 grid grid-flow-col auto-cols-[1fr_200px] gap-2 items-start'>
    <ValidatedTextInput
      ref={inputRef}
      className='p-2 border'
      label='To-do description'
      placeholder='Todo description'
      name='description'
      value={editTodo?.description}
      disabled={loadingContext.isLoading}
    />
    <Button
      className='text-black'
      type='submit'
      name='_action'
      value='upsert'
      disabled={loadingContext.isLoading}
    >
      {editTodo ? 'Edit' : 'Add'}
    </Button>
  </div>
</ValidatedForm>

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 πŸŽ‰

Next

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


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