Powerful Form Validation With React Hook Form and Zod

Zod is a lightweight and powerful validation library for JavaScript and TypeScript applications. It enables you to define the structure of your data through schemas and validate the input data against those schemas. Although it can be used with JavaScript applications, Zod works greatly with TypeScript. Zod can automatically generate TypeScript types from your schema, keeping the validation schema and TypeScript types in sync.

TypeScript, React Hook Form and Zod are a great combo that allows you to build powerful forms. As a result, you will learn how to validate your React Hook Form with Zod and TypeScript.

This article uses the form built in the "How to create and validate forms with React Hook Form (RHF)" article.

Prerequisites

You should have the following packages installed in your project before continuing further:

@hookform/resolvers zod

Also, you should have TypeScript installed and configured in your project.

Add Zod

Import the required dependencies at the top of the file:

import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

zodResolver is a function for integrating an external validation library like Zod with the React Hook Form library. The function takes the schema you define as an argument.

z enables you to access Zod's functions and features.

Define the schema

Now you can start building the schema by creating a Zod object. An object schema allows you to define the structure and type of the form data. You can define multiple data inputs, specify their types, and add validation logic.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const FormSchema = z.object({
    username: z.string(),
    email: z.string(),
    isAdmin: z.boolean(),
    createdAt: z.coerce.date(),
});

The FormSchema object defines a schema object with four fields and their types:

  • username: string
  • email: string
  • isAdmin: boolean
  • createdAt: date

At the moment, the schema is simple. It only specifies the data types. Later, you will add validation logic, such as checking that the username doesn't contain special characters (@, !, etc.), for instance.

Infer TypeScript types from Zod schema

The official website mentions that Zod is "TypeScript-first schema validation with static type inference". That means you can automatically infer TypeScript types from a Zod schema.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const FormSchema = z.object({
    username: z.string(),
    email: z.string(),
    isAdmin: z.boolean(),
    createdAt: z.coerce.date(),
});

type FormInput = z.infer<typeof FormSchema>;

The FormInput type now matches the schema. You can check it by hovering over the FormInput type, which should show the data inputs and their types.

TypeScript types inferred from the zod schema

The possibility of automatically inferring TypeScript types from the Zod schema is beneficial because it ensures type safety and consistency between the two.

Pass Zod schema to React Hook Form

Until this point, you have the form schema and input types. But you are not using them. So let's pass them to the useForm hook:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const FormSchema = z.object({
    username: z.string(),
    email: z.string(),
    isAdmin: z.boolean(),
    createdAt: z.coerce.date(),
});

type FormInput = z.infer<typeof FormSchema>;

export default function Form() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<FormInput>({
        resolver: zodResolver(FormSchema),
        defaultValues: {
            username: '',
            email: '',
            isAdmin: true,
            createdAt: new Date(),
        },
    });
    
    .....
}

In the above code, you:

  1. set up a React Hook Form instance using the useForm hook
  2. pass a configuration object to the useForm hook as an argument
  3. set the resolver property to the result of calling the zodResolver function with the FormSchema as an argument
  4. define the default values for the form fields

The useForm hook returns a couple of properties, such as register, which allows you to register input and select elements for value tracking and validation or formState, which is an object that contains information about the entire form state. It also returns the handleSubmit function, which receives the form data if the form validation is successful. You use this method to handle the form submission.

Other properties are returned, but we're only interested in these three in this case. You can see all the properties in the documentation.

Build the JSX form

The last step involves writing the JSX code for the form. This is the complete code for the form:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const FormSchema = z.object({
    username: z.string(),
    email: z.string(),
    isAdmin: z.boolean(),
    createdAt: z.coerce.date(),
});

type FormInput = z.infer<typeof FormSchema>;

export default function Form() {
    const {
        register,
        handleSubmit,
        formState: { errors },
    } = useForm<FormInput>({
        resolver: zodResolver(FormSchema),
        defaultValues: {
            username: '',
            email: '',
            isAdmin: true,
            createdAt: new Date(),
        },
    });

    return (
        <form onSubmit={handleSubmit((d) => console.log(d))}>
            <div>
                <label htmlFor="username">Username</label>
                <input id="username" {...register('username')} />
                {errors?.username?.message && <p>{errors.username.message}</p>}
            </div>

            <div>
                <label htmlFor="email">Email</label>
                <input id="email" {...register('email')} />
                {errors?.email?.message && <p>{errors.email.message}</p>}
            </div>

            <div>
                <label htmlFor="isAdmin">IsAdmin</label>
                <input id="isAdmin" type="checkbox" {...register('isAdmin')} />
            </div>

            <div>
                <label htmlFor="createdAt">Creation Date</label>
                <input id="createdAt" type="date" {...register('createdAt')} />
                {errors?.createdAt?.message && (
                    <p>{errors.createdAt.message}</p>
                )}
            </div>

            <button type="submit">Submit</button>
        </form>
    );
}

This is how the form works up to this point:

React Hook Form without validation logic

There are no validation rules, meaning the users can submit the form without entering any data. Moreover, they can also enter erroneous data and still submit the form. You want to avoid that happening, though. Instead, you want to make sure the users only enter valid data. So, in the next step, you'll add some validation logic.

You can see the complete form code with the styling in this gist.

Add validation logic

We want the form to display errors when users enter invalid data. To do that, you need to modify the form schema. Let's start with the username input field. The username should:

  • be a string
  • have at least 4 characters
  • have a maximum of 10 characters
  • only contain letters, numbers and underscore

Zod provides a list of string validation functions you can use. We'll use the min, max, and regex functions to validate the username input field. These functions also allow you to pass a message argument for a custom error message.

const FormSchema = z.object({
  username: z
    .string()
    .min(4, { message: "The username must be 4 characters or more" })
    .max(10, { message: "The username must be 10 characters or less" })
    .regex(
      /^[a-zA-Z0-9_]+$/,
      "The username must contain only letters, numbers and underscore (_)"
    ),
});

If you try to enter an invalid username now, the form will not submit, and it will display the appropriate error message.

The email address should also be valid. Zod provides an email function that deals with email addresses.

const FormSchema = z.object({
  username: z
    .string()
    .min(4, { message: "The username must be 4 characters or more" })
    .max(10, { message: "The username must be 10 characters or less" })
    .regex(
      /^[a-zA-Z0-9_]+$/,
      "The username must contain only letters, numbers and underscore (_)"
    ),
  email: z.string().email({
    message: "Invalid email. Please enter a valid email address",
  }),
});

The isAdmin field can stay as it is. Therefore, we will not add any validation logic.

const FormSchema = z.object({
  username: z
    .string()
    .min(4, { message: "The username must be 4 characters or more" })
    .max(10, { message: "The username must be 10 characters or less" })
    .regex(
      /^[a-zA-Z0-9_]+$/,
      "The username must contain only letters, numbers and underscore (_)"
    ),
  email: z.string().email({
    message: "Invalid email. Please enter a valid email address",
  }),
  isAdmin: z.boolean(),
});

The createdAt field should have the following validation rules:

  • the date cannot go past January 1 1920
  • the date should be in the past
  • the user should be 18 years or older

You can enforce those validation rules using the min, max, and refine methods provided by Zod. In addition, the refine method lets you create your custom validation logic. In this case, the code checks if the age of the user submitting the form is 18 or older. If it's younger, it displays the error message "You must be 18 years or older", and the user cannot submit the form.

const FormSchema = z.object({
  username: z
    .string()
    .min(4, { message: "The username must be 4 characters or more" })
    .max(10, { message: "The username must be 10 characters or less" })
    .regex(
      /^[a-zA-Z0-9_]+$/,
      "The username must contain only letters, numbers and underscore (_)"
    ),
  email: z.string().email({
    message: "Invalid email. Please enter a valid email address",
  }),
  isAdmin: z.boolean(),
  createdAt: z.coerce
    .date()
    .min(new Date(1920, 0, 1), {
      message: "Date cannot go past January 1 1920",
    })
    .max(new Date(), { message: "Date must be in the past" })
    .refine(
      (date) => {
        const ageDifMs = Date.now() - date.getTime();
        const ageDate = new Date(ageDifMs);

        const age = Math.abs(ageDate.getUTCFullYear() - 1970);

        return age >= 18;
      },
      { message: "You must be 18 years or older" }
    ),
});

You might've also observed the coerce method. If you don't use the coerce method, the form throws the following error "Expected date, received string".

zod date error without the coerce method

The coerce method lets you transform or modify the user input before it's validated against the schema. In this case, it converts the date string to a Date object by passing the input through new Date(input).

Final form

You now have a working React Hook Form with data validation using Zod and TypeScript.

React Hook Form with Zod validation and TypeScript

In this article, you learnt how to:

  • use react-hook-form with Zod and TypeScript
  • create a schema
  • infer the types from the Zod validation schema
  • validate your form data using Zod

If you want to learn how to build a form with react-hook-form and use its built-in validation mechanisms, check the "How to create and validate forms with React Hook Form (RHF)" article.

If you're using React Hook Form and Zod, you might also be interested in the following articles: