In this tutorial you will learn how to use react-hook-form and zod to build a form with validation.

If you prefer a video tutorial instead, you can watch it below.

Clone the project from GitHub.

This is what we are going to be building:

Let's start with a little bit of boilerplate code for our form component:

import { FC } from "react";

const Tiers = [
  {
    id: "BRONZE",
    name: "Bronze",
    description: "Get average points",
    price: 0.99,
  },
  {
    id: "SILVER",
    name: "Silver",
    description: "Get extra points",
    price: 4.99,
  },
  {
    id: "GOLD",
    name: "Gold",
    description: "The highest possible tier",
    price: 19.99,
  },
];

export const Form: FC = () => {

  return (
    <form className="space-y-10">
      <div>
        <label className="block">
          <span className="block">Email</span>
          <input
            type="text"
            className={`block border text-lg px-4 py-3 mt-2 rounded-lg border-gray-200 focus:bg-white text-gray-900 focus:border-blue-600 focus:ring-0 outline-none w-full  disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed`}
          />
        </label>
      </div>
      <div>
        <label className="flex items-center">
          <input
            type="checkbox"
            className="block border text-lg rounded w-6 h-6 border-gray-200 text-blue-600 focus:ring-0 focus:outline-none focus:ring-offset-0 disabled:text-gray-200 disabled:cursor-not-allowed"
          />
          <span className="block ml-4">I accept the Terms of Service</span>
        </label>
      </div>
      <div>
        <p className="block">Payment Tier</p>
        <ul className="space-y-2 mt-2">
          {Tiers.map((tier) => {
            return (
              <li
                className={`border rounded-lg border-gray-200 text-gray-900`}
                key={tier.id}
              >
                <label
                  className={`flex justify-between px-6 py-4 items-center cursor-pointer`}
                >
                  <div>
                    <p className={`font-medium text-lg`}>{tier.name}</p>
                    <p className={`text-sm opacity-80`}>{tier.description}</p>
                  </div>
                  <div className="flex items-center">
                    <p className={`font-medium mr-4 text-sm`}>
                      {tier.price.toLocaleString("en-US", {
                        currency: "USD",
                        style: "currency",
                      })}
                    </p>
                    <input
                      type="radio"
                      className="w-6 h-6 border ring-0 border-gray-200 text-blue-600 disabled:text-gray-300 outline-none focus:ring-0 focus:ring-offset-0 cursor-pointer"
                      value={tier.id}
                    />
                  </div>
                </label>
              </li>
            );
          })}
        </ul>
      </div>
      <button
        type="submit"
        className="w-full px-8 py-4 flex items-center justify-center uppercase text-white font-semibold bg-blue-600 rounded-lg disabled:bg-gray-100 disabled:text-gray-400"
      >
        Create account
      </button>
    </form>
  );
};

This just gets us the form with styling without any functionality added yet.

Building a form validation schema with zod

Let's build a schema that matches the values in our form.

Let's start off by importing the necessary libraries:

import { z } from "zod";
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

And let's define our schema with some custom error messages:

const FormSchema = z.object({
  email: z.string().email(),
  accept: z.literal(true, {
    invalid_type_error: "You must accept Terms and Conditions.",
  }),
  tier: z
    .string({ invalid_type_error: "Please select a payment tier." })
    .refine((val) => Tiers.map((tier) => tier.id).includes(val)),
});

We will use a string validation chained with an email validation for our email field.

For the accept terms of service checkbox we will use literal validator with the value of true. Literal just means that the field must be exactly this value. Notice that we are also using a custom error message for invalid_type_error. Later in this tutorial you will learn how to show the error messages.

For our payment tier validation we first check if the value is a string and then use a custom validation using refine to check if the string matches one of the IDs from our predefined Tiers array.

Let's infer a type from it that we are going to use moving forward:

type FormSchemaType = z.infer<typeof FormSchema>;

We can see that TypeScript inferred the following type from it:

type FormSchemaType = {
    email: string;
    accept: true;
    tier: string;
}

This will help us keep all our functions type safe.

Using react-hook-form

Let's use react-hook-form to handle our form state.

Add this code inside your Form component:

export const Form: FC = () => {
  const {
    register,
    watch,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormSchemaType>({
    resolver: zodResolver(FormSchema),
  });

  const onSubmit: SubmitHandler<FormSchemaType> = async (data) => {
    await new Promise(async (resolve) => {
      await setTimeout(() => {
        console.log(data);
        resolve(undefined);
      }, 3000);
    });
  };

We have used the useForm function and given it the type of our schema. This will help TypeScript to properly keep our code type safe.

We have created a onSubmit function that after a 3 second delay will log the validated form data into the console. I wanted to add an artificial delay to better emulate a real world scenario.

If we try filling the form and submitting it, nothing happens. This is because we haven't yet registered the form inputs or made the form to use our custom onSubmit function.

Registering inputs

We can register the form inputs by using the register function we get from useForm by giving the name of the field that matches the one in our schema.

For example for the email field:

          <input
            type="text"
            className={`block border text-lg px-4 py-3 mt-2 rounded-lg border-gray-200 focus:bg-white text-gray-900 focus:border-blue-600 focus:ring-0 outline-none w-full  disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed`}
            {...register("email")}
          />

And accept terms of service:

          <input
            type="checkbox"
            className="block border text-lg rounded w-6 h-6 border-gray-200 text-blue-600 focus:ring-0 focus:outline-none focus:ring-offset-0 disabled:text-gray-200 disabled:cursor-not-allowed"
            {...register("accept")}
          />

And for the payment tier radio button:

                    <input
                      type="radio"
                      className="w-6 h-6 border ring-0 border-gray-200 text-blue-600 disabled:text-gray-300 outline-none focus:ring-0 focus:ring-offset-0 cursor-pointer"
                      value={tier.id}
                      {...register("tier")}
                    />

Using custom onSubmit handler

The handleSubmit function we get from useForm does two things. First it disables any default form submission behaviors, and second it calls our custom onSubmit function with the validated data.

    <form className="space-y-10" onSubmit={handleSubmit(onSubmit)}>

Now if you try filling the form and submitting it you will see that after 3 seconds the validated form values get logged into the console.

If you fill the form with invalid values you will see that the correct error messages appear.

One problem you might have noticed is that you can click on the create account button multiple times and the form will submit multiple times. This is obviously something we don't want to happen.

Let's fix that by disabling all form inputs and the submit button when the form is submitting.

Disabling form inputs

We will use the isSubmitting value we get from formState which we get from useForm to check whether the form is currently submitting or not.

For our inputs and submit button we will disable them using this value.

Example for our email input:

          <input
            type="text"
            className={`block border text-lg px-4 py-3 mt-2 rounded-lg border-gray-200 focus:bg-white text-gray-900 focus:border-blue-600 focus:ring-0 outline-none w-full  disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed`}
            {...register("email")}
            disabled={isSubmitting}
          />

Add the disabled={isSubmitting} attribute to other fields and the submit button.

Now when you submit the form you will notice that all the fields and the submit button get disabled until the data gets logged into the console.

But what about if the form is not valid?

Show error messages

Currently if you try submitting a form with invalid fields, nothing happens.

Let's change that by conditionally showing error messages for each of the fields if they are invalid.

For our email field:

          <input
            type="text"
            className={`block border text-lg px-4 py-3 mt-2 rounded-lg border-gray-200 focus:bg-white text-gray-900 focus:border-blue-600 focus:ring-0 outline-none w-full  disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed`}
            {...register("email")}
            disabled={isSubmitting}
          />
        </label>
        {errors.email && (
          <p className="text-sm text-red-600 mt-1">{errors.email.message}</p>
        )}

and accept terms of service button:

        {errors.accept && (
          <p className="text-sm text-red-600 mt-1">{errors.accept.message}</p>
        )}

and for payment tiers after the ul tags:

        {errors.tier && (
          <p className="text-sm text-red-600 mt-1">{errors.tier.message}</p>
        )}

Now when you try submitting the form with invalid fields you should see the error messages showing.

The default behavior of react-hook-form is to validate the form when submitting for the first time. After this it will validate the form after every key press and blur event.

Conclusion

In this tutorial you learned how to combine react-hook-form and zod to create a fully fledged form with validation.

For next steps, dive into the react-hook-form documentation to learn more advanced concepts such as: dynamically generated fields and multi step forms.