Building Forms with zod and react-hook-form

May 29, 2023
Blog cover
9 min read

Creating forms that elegantly handle validation in an accessible, maintainable and extendable way is a challenge. Using zod and react-hook-form we can take a declarative approach that decouples our form, validation and component logic.

We'll be using the following libraries:

  • zod: Runtime validation allowing us to declare schemas that parse data users input and generates a validation message.
  • react-hook-form: Gives us a hook to control our form and trigger validation.
  • Material UI: Component library to style our form input fields.

It should be noted that apart from react-hook-form, these libraries are interchangeable. For example, you could use io-ts instead of zod to define your form schemas, or Bootstrap instead of Material UI.

Rendering a Simple Field

Let's start by creating a simple uncontrolled field for the user to input their email.

1<form noValidate> 2 <TextField 3 name="email" 4 label="Email" 5 required 6 placeholder="example@mail.com" 7 /> 8</form> 9
tsx

A few things to note:

  • label="Email" defines the visual label on the MUI (Material UI) component, while name="email" is the native HTML name attribute which allows the browser to make suggestions among other things (so you can quickly enter the email you've used on other sites). This may not be desirable in some cases, so you can choose to omit the name prop or add autoComplete="off" to the form element.
  • We use noValidate on the form to disable native HTML validation. We'll be using our own custom zod codec to define our own flexible validation rules to and have a consistent UX across different browsers.
  • We haven't specified an onChange or value prop, so the field is currently uncontrolled. We'll be using react-hook-form to manage this for us.
  • required adds an HTML required attribute which has accessibility properties, and adds a * to the label.

Controlling the Form

Let's introduce react-hook-form to control this input. The core of this library is the useForm hook, lets add that to our form:

1const { control, handleSubmit } = useForm({ 2 defaultValues: { 3 email: "", 4 }, 5}); 6
tsx

At the moment, we only have a single field so we're just saying that our email input field is empty by default. You'll also notice we don't have anything describing the 'shape' of our form, or any type safety for the data our form contains - keep that in mind for later.

We now have a form control, which allows us to do a lot of magic like watch the data a user inputs into a field, handle validation, and much more. But we need to tell this control what the email input component is. To do this, we wrap the TextField in a Controller like so:

1<Controller 2 name="email" 3 control={control} 4 render={({ 5 field: { value, onChange, onBlur, ref }, 6 fieldState: { error }, 7 }) => ( 8 <TextField 9 name="email" 10 label="Email" 11 placeholder="example@mail.com" 12 required 13 inputRef={ref} 14 value={value} 15 onChange={onChange} 16 onBlur={onBlur} 17 error={Boolean(error)} 18 /> 19 )} 20/> 21
tsx

This does a few things:

  • name="email" on the Controller tells our form controller (control={control}) which field this is. Again, this is different to the name="email" on TextField which is used by the browser for suggestions, etc.
  • render passes a few props into the MUI TextField:
    • value and onChange so that the input can be managed by react-hook-form.
    • ref so that the controller can move the user's focus to the input field (e.g., when trying to submit the form and the field is in an invalid state).
    • error just gives the MUI component a red outline to make it visually clear when the field is invalid.
    • onBlur so that the controller can trigger events (such as validation) when the user is no longer focusing the input.

Note that react-hook-form can also register fields instead. Since we're using a component library, we need to use a Controller, but register will work if you're using a custom HTML input.

Let's also add some logic to submit our form:

  • A button within form:

    1<Button type="submit">Submit</Button> 2
    tsx
  • A function to run on submit (this could be submitting form data to an API, etc.):

    1const onSubmit = (data) => console.log(data); 2
    tsx
  • Then hook it up to our form with the prop:

    1onSubmit={handleSubmit(onSubmit)} 2
    tsx

You should now be able to hit the button and see a JSON object with your inputted email logged to the console. But we have a few problems:

  • The email is required, but we can still submit our form without specifying it.
  • We can enter an email in an invalid format, and our form will still accept it.
  • We don't have any way to display messages to the user when these validation rules fail.
  • As mentioned earlier, we still don't have good type safety.

Creating a Schema

First, let's make the email field required. To do this, we're going create a zod schema. While this is a relatively simple example, a library like zod allows us to:

  • Define a set of validation rules and a dynamic message we can display to the user depending on any violated rule.
  • Allows us to infer TypeScript definitions from that schema.
  • transform or coerce data inputted by the user. Maybe we need to convert an unknown something to a number, or strip whitespace. Importantly, this 'parsing' logic is tied to the validation logic and happens at the boundaries of our application.
  • More generally, gives us runtime type checking, so we can make sure the raw data inputted by the user is in a form that our application can consume (why is this important? Read this).

We're going to start with:

1const formSchema = z.object({ 2 email: z.string().nonempty(), 3}); 4type FormFields = z.infer<typeof formSchema>; 5
tsx

then pass it to the useForm hook using the zodResolver from @hookform/resolvers:

1const { control, handleSubmit } = useForm<FormFields>({ 2 mode: "onBlur", 3 reValidateMode: "onBlur", 4 resolver: zodResolver(formSchema), 5 defaultValues: { 6 email: "", 7 }, 8}); 9
tsx

Boom. You should be able to hit submit without specifying an email, then the outline will turn red and be focused. We don't disable the submit button if the form is in an invalid state. This allows the user to more easily identify what in the form is invalid (you can imagine how important this is in a much larger form than this). You may want to render the button differently, but it should still be interactive. Let's break down what's happening here:

  • We define that the email field is a string() that is nonempty() using zod. Fields in zod are required by default. The nice thing about zod is that by default it strips any unexpected keys that aren't defined in schema, which makes your application more secure (read more).
  • We infer the TypeScript type FormFields and pass it to the useForm type param, so now the output data is properly typed and any Controller we define.
  • Set the validation mode to onBlur. This is ideal for accessibility, since the user validation status doesn't constantly change as the user types, but they don't need to submit before knowing whether that field is valid or not. We also set reValidateMode so that the behaviour is consistent after the first submit.

It's worth noting that react-hook-form has it's own built-in rules, but these don't give us anywhere near the flexibility of zod.

Displaying a Validation Error

Next, let's improve this by rendering out the validation message to the user. Update the render prop within the Controller:

1render={({ 2 field: { value, onChange, onBlur, ref }, 3 fieldState: { error }, 4}) => ( 5 <FormControl> 6 <TextField 7 name="email" 8 label="Email" 9 placeholder="example@mail.com" 10 required 11 inputRef={ref} 12 value={value} 13 onChange={onChange} 14 onBlur={onBlur} 15 error={Boolean(error)} 16 /> 17 <FormHelperText 18 sx={{ 19 color: 'error.main', 20 }} 21 > 22 {error?.message ?? ''} 23 </FormHelperText> 24 </FormControl> 25)} 26
tsx

We're using the MUI FormControl and FormHelperText to associate an error message with the input field, if one exists.

Now we can see an error, but not one that is particularly readable:

String must contain at least 1 character(s)

That's because we're using the built in zod error reporter. We can define our own message instead:

1const formSchema = z.object({ 2 email: z.string().nonempty("Please specify an email"), 3}); 4
tsx

However, we still allow an invalid email. Thankfully, zod has a built in email() schema:

1const formSchema = z.object({ 2 email: z 3 .string() 4 .nonempty("Please specify an email") 5 .email("Please specify a valid email"), 6}); 7
tsx

Now we have a dynamic error message to help the user identify what they got wrong.

Adding Another Field

Let's extend our form with an optional mobile phone number. This has some implications to consider:

  • We need to make sure the input only contains digits. But a user might try to enter the number in different formats (0400-000-000 or 0400 000 000).
  • It must be exactly 10 digits long.
  • It must start with "04".

Let's translate that into schema:

1const mobilePhoneNumberSchema = z 2 .string() 3 // Remove all non-digit characters. 4 .transform((value) => value.replace(/\D/gu, "")) 5 // Must be 10 digits. 6 .refine((value) => value.length === 10, "Please specify 10 digits") 7 // Mobile, Cellular, and Satellite services use the code 04. 8 .refine( 9 (value) => value.startsWith("04"), 10 "Mobile numbers must start with '04'" 11 ); 12
tsx

There are two new parts to this schema:

  • transform allows us to manipulate the raw value inputted by the user.
  • Instead of using a built-in validation rule like email, we define our own custom checks with refine.

Now we need to add it to the form schema:

1const formSchema = z.object({ 2 phone: mobilePhoneNumberSchema, 3 ... 4
tsx

set the default in the useForm hook:

1 defaultValues: { 2 phone: '', 3 ... 4
tsx

and add the input field within the form:

1<form noValidate onSubmit={handleSubmit(onSubmit)}> 2 <Controller 3 name="phone" 4 control={control} 5 render={({ 6 field: { value, onChange, onBlur, ref }, 7 fieldState: { error }, 8 }) => ( 9 <FormControl> 10 <TextField 11 name="phone" 12 label="Phone" 13 placeholder="0400-000-000" 14 inputRef={ref} 15 value={value} 16 onChange={onChange} 17 onBlur={onBlur} 18 error={Boolean(error)} 19 /> 20 <FormHelperText 21 sx={{ 22 color: 'error.main', 23 }} 24 > 25 {error?.message ?? ''} 26 </FormHelperText> 27 </FormControl> 28 )} 29 /> 30 ... 31
tsx

One last thing - we want to make the field optional. We could update some logic in our reusable mobilePhoneNumberSchema, but we might want to use that in other parts of our app where it is required. Instead, let's try adding .optional() to mobilePhoneNumberSchema in the formSchema.

Looks good right? Well not quite. What happens if we enter something, then delete it? It still says:

Please specify 10 digits

So we aren't quite there. This is because .optional() actually only checks if the value is undefined, and once the field is dirty, the value instead becomes an empty string. There are a few other cases where this can happen, such as an MUI select component being null. Here is a helper that will preprocess (similar to transform, but happens before parsing) empty strings and nulls to undefined to make the optional behaviour work as expected:

1const looseOptional = <T extends z.ZodTypeAny>(schema: T) => 2 z.preprocess( 3 (value: unknown) => 4 value === null || (typeof value === "string" && value === "") 5 ? undefined 6 : value, 7 schema.optional() 8 ); 9
tsx

Now we can wrap the mobilePhoneNumberSchema with that utility and get the behaviour we expect:

1const formSchema = z.object({ 2 phone: looseOptional(mobilePhoneNumberSchema), 3 ... 4
tsx

Some Extra Gotchas

  • Make sure you only specify required on the input component when it is also required in schema.

  • You may have multiple forms or even nested forms in complex scenarios, in which case a button with type="submit" might get in your way and trigger submit on multiple forms (very annoying). This is because native HTML is once again getting in our way, you can override it by changing the form onSubmit:

    1onSubmit={(event) => { 2 // Prevent the event from triggering other active forms. 3 event.preventDefault(); 4 event.stopPropagation(); 5 6 void handleSubmit(onSubmit)(event); 7}} 8
    tsx
  • If you need to coerce a value (e.g., a Date via MUI date picker), you can use z.coerce.date() in addition to other primitives respectively. However, you may want a custom validation message in some cases, in which case you'll need to use an error map instead:

    1z.date({ 2 errorMap: (issue, ctx) => 3 issue.code === z.ZodIssueCode.invalid_date 4 ? { message: "Please specify a date" } 5 : { message: ctx.defaultError }, 6 coerce: true, 7}); 8
    tsx

Wrapping Up

Using these three libraries, we are able to decouple our form, validation, and component logic, meaning we can easily define reusable schemas, or swap to a completely different component library with ease. We also get the safety of runtime type checking, while having accessible user-friendly forms with helpful validation messages.

You can find the source code for the example on Stackblitz.

Further Reading

Buy Me a Coffee at ko-fi.com
© Tim James - ABN 31 445 198 482