Remix Forms with zod — Part 1: tackling formData

Yarden Hochman
Venn Engineering Blog
3 min readJan 8, 2023

Why read this article:

In this article I want to show how to quickly and easily validate and parse form data in Remix in one line of code into fully typed objects.

Remix makes forms easy — native HTML is king again

Working with forms in React is notoriously painful, you need to create state variables and handlers to track changes on each field, validate them, then manually send the field values to the server.

Using native HTML forms is much simpler because your form handles its own state, and utilizes the server for validation.

The standard way to handle forms in HTML is native form elements containing a submit button. Clicking the button will submit to the server a FormData instance containing all the form’s values.

But FormData is a pain to work with…

Unfortunately the FormData class provides no information on what data it contains. The naive way of working with it involves painfully extracting each parameter value, which is retrieved without any type information. This makes FormData hard to work with.

the naive way to extract the form’s data on the server. We need to “get” each form field’s value
Notice that we have no type information. Everything is just “FormDataEntryValue”.

Use Zod and zod-form-data to validate and parse FormData

Links to libraries:

Zod

Zod Form Data

Zod is one of the most used validation libraries today. We can use it to specify what type of form data would be valid and what errors to throw when it is not.

Here is an example of an event object’s schema.

const eventSchema = z.object({
duration: zfd.numeric(
z.number().min(0, { message: `please specify your event's duration` }),
),
admission: zfd.numeric(z.number().min(0).optional()),
maxTickets: zfd.numeric(z.number().min(0).optional()),
startDate: zfd.text(
z.string({ required_error: 'Please select a date and time' }),
),
location: zfd.text(z.enum(LOCATION_VALUES)), //enums
imageResourceId: zfd.text(z.string()),
timezone: zfd.text(z.string()),
imageFormat: zfd.text(z.string()),
organizers: zfd.text(z.string().optional()),
description: zfd.text(z.string()),
buildingSpecific: zfd.checkbox(),
buildingIds: zfd.repeatable(z.array(zfd.text()).optional()),
place: zfd.text(z.string().optional()),
virtualLink: zfd.text(z.string().optional()),
name: zfd.text(z.string().min(1, { message: `please provide a name` })),
intent: zfd.text(
z.enum([ //enums
EVENT_INTENT.Publish,
EVENT_INTENT.SaveAsDraft,
EVENT_INTENT.SaveChanges,
]),
),
})
export const submitEventValidator = withZod(eventSchema);

the zfd portion specifies the type of value you expect to be getting (repeatable = array, text = string, etc).

You can infer a Typescript interface based on this schema definition:

export type EventType = z.infer<typeof upsertEventSchema>;

The resulting type:

Now we can validate and parse in one line!

const result = await submitEventValidator.validate(await request.formData());
if (result.error) {
return validationError(result.error, result.submittedData);
}
//success result's code after type guard

Notice that we are returning an error if validation failed. This is crucial because we cannot let a user submit the wrong information, and it functions as a type guard because from this point on, Typescript knows that “result” contains a valid event object.

So now we have all the information we expected to receive on the server, all this with one line of code!

--

--