Validating forms with Zod and react-hook-form
React Hook Form serves as a versatile and efficient library designed for managing forms within React applications.
React Hook Form serves as a versatile and efficient library designed for managing forms within React applications. Its usage is both flexible and straightforward. In contrast, Zod stands out as a TypeScript-oriented library primarily focused on schema declaration and validation.
This article guides you through the process of constructing a basic registration form by leveraging React Hook Form along with Zod as the validation library. The tutorial also incorporates TypeScript and TailwindCSS for styling, although the styling implementations remain rudimentary.
Installation
We’ll use Vite with TypeScript to create our project. You can use any one of the following commands:
npm create vite@latest
or
yarn create vite
or
pnpm create vite
Subsequently, we proceed to install TailwindCSS. The installation process for TailwindCSS in this project adheres to the standard setup outlined in the "Install Tailwind CSS with Vite" guide. Click on the provided link, meticulously follow the instructions, and return to this point once the installation is complete.
Moving forward, we will integrate React Hook Form, Zod, and @hookform/resolvers. The inclusion of @hookform/resolvers facilitates the utilization of external validation libraries, as is the case with Zod in our project.
npm install react-hook-form @hookform/resolvers zod
And that’s it! We’ve installed all the necessary dependencies for the project.
First let's create a new component called Form
import React from "react";
const Form = () => {
return (
<form className="px-8 pt-6 pb-8 mb-4">
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="firstName"
>
First Name
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="firstName"
type="text"
placeholder="First Name"
/>
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="lastName"
>
Last Name
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="lastName"
type="text"
placeholder="Last Name"
/>
</div>
</div>
<div className="mb-4">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="email"
>
Email
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Email"
/>
</div>
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="password"
>
Password
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="password"
type="password"
/>
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="c_password"
>
Confirm Password
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="c_password"
type="password"
/>
</div>
</div>
<div className="mb-4">
<input type="checkbox" id="terms" />
<label
htmlFor="terms"
className="ml-2 mb-2 text-sm font-bold text-gray-700"
>
Accept Terms & Conditions
</label>
</div>
<div className="mb-6 text-center">
<button
className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
type="submit"
>
Register Account
</button>
</div>
<hr className="mb-6 border-t" />
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="#"
>
Forgot Password?
</a>
</div>
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="#"
>
Already have an account? Login!
</a>
</div>
</form>
);
};
export default Form;
Now we can import Form in our App.tsx
import React from "react";
import Form from "./Form";
function App() {
return (
<div className="max-w-xl mx-auto w-full">
<div className="flex justify-center my-12">
<div className="w-full lg:w-11/12 bg-white p-5 rounded-lg shadow-xl">
<h3 className="pt-4 text-2xl text-center font-bold">
Create New Account
</h3>
<Form />
</div>
</div>
</div>
);
}
export default App;
Validation schema using Zod
Zod operates as a TypeScript-centric schema declaration and validation library. The "TypeScript-first" approach implies that Zod seamlessly deduces the static TypeScript type for your data structure, requiring you to declare a validator only once. This eliminates the need for redundant type declarations—first in Zod and then again in TypeScript. Leveraging z.infer<typeof schema>
with the "infer" keyword allows you to automatically extract the type from a schema. This feature proves particularly advantageous for TypeScript users, streamlining the typing process.
It's working like this :
import { zod } from "Zod";
const personSchema = z.object({
name: z.string(),
age: z.number()
});
// extracting the type
type Person = z.infer<typeof personSchema>;
Let’s import the necessary dependencies in src/Form.tsx
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
Subsequently, we'll articulate our schema, validationSchema, incorporating custom error messages. This step takes place in the src/Form.tsx file, positioned between the import statement and our Form component.
const validationSchema = z
.object({
firstName: z.string().min(1, { message: "Firstname is required" }),
lastName: z.string().min(1, { message: "Lastname is required" }),
email: z.string().min(1, { message: "Email is required" }).email({
message: "Must be a valid email",
}),
password: z
.string()
.min(6, { message: "Password must be atleast 6 characters" }),
confirmPassword: z
.string()
.min(1, { message: "Confirm Password is required" }),
terms: z.literal(true, {
errorMap: () => ({ message: "You must accept Terms and Conditions" }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Password don't match",
});
Let's deconstruct the code step by step.
We initiate the declaration of validationSchema using the z.object() method. Specifically, for the firstname and lastname fields, we employ string validation coupled with a minimum length validation using z.string().min(1, { message: 'Firstname is required'})
. This construct ensures that the input data is not only a string but also one or more characters long, effectively making the field required. Custom error messages, such as 'Firstname is required', can be assigned when utilizing validation methods. The process of displaying these error messages will be covered later in our exploration.
Moving on to the email field, we employ string validation, minimum length validation, and email validation. Zod provides various string-specific validations, with 'email' being one of those options.
For the password field, the validation starts with string validation, followed by a check that the entered password must be 6 or more characters long. As for the confirmPassword field, we validate it similarly to firstname and lastname. Additionally, we need to ensure that both the password and confirmPassword match. Zod enables the incorporation of custom validation logic through refinements, specifically the refine method.
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"], // path of error
message: "Password don't match",
});
This will help us keep all our functions type-safe. Now our Form.tsx component:
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const validationSchema = z
.object({
firstName: z.string().min(1, { message: "Firstname is required" }),
lastName: z.string().min(1, { message: "Lastname is required" }),
email: z.string().min(1, { message: "Email is required" }).email({
message: "Must be a valid email",
}),
password: z
.string()
.min(6, { message: "Password must be atleast 6 characters" }),
confirmPassword: z
.string()
.min(1, { message: "Confirm Password is required" }),
terms: z.literal(true, {
errorMap: () => ({ message: "You must accept Terms and Conditions" }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Password don't match",
});
type ValidationSchema = z.infer<typeof validationSchema>;
const Form = () => {
return (
<form className="px-8 pt-6 pb-8 mb-4">
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="firstName"
>
First Name
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="firstName"
type="text"
placeholder="First Name"
/>
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="lastName"
>
Last Name
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="lastName"
type="text"
placeholder="Last Name"
/>
</div>
</div>
<div className="mb-4">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="email"
>
Email
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Email"
/>
</div>
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="password"
>
Password
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="password"
type="password"
/>
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="c_password"
>
Confirm Password
</label>
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="c_password"
type="password"
/>
</div>
</div>
<div className="mb-4">
<input type="checkbox" id="terms" />
<label
htmlFor="terms"
className="ml-2 mb-2 text-sm font-bold text-gray-700"
>
Accept Terms & Conditions
</label>
</div>
<div className="mb-6 text-center">
<button
className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
type="submit"
>
Register Account
</button>
</div>
<hr className="mb-6 border-t" />
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="#"
>
Forgot Password?
</a>
</div>
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="#"
>
Already have an account? Login!
</a>
</div>
</form>
);
};
export default Form;
React hook form
First lets import it :
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
We'll add the following code snippet to the Form.tsx component:
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ValidationSchema>({
resolver: zodResolver(validationSchema),
});
const onSubmit: SubmitHandler<ValidationSchema> = (data) => console.log(data);
The useForm
hook is a custom utility that simplifies form management. It accepts an optional object as an argument, and in our case, we'll utilize the resolver
property. This property enables the use of any external validation library, such as Zod. We employ the zodResolver
hook from @hookform/resolvers/zod
, passing it the validationSchema
validator. It's important to note that we've assigned types deduced by Zod to useForm
and SubmitHandler
.
We've defined the onSubmit
function, which will log the validated data to the console. The onSubmit
function will only be invoked once the handleSubmit
function, provided by useForm
, has validated the inputs. At this point, we've set up these functions and other essential elements, including register
and formState
, with the useForm
hook.
However, it's crucial to observe that attempting to fill in the form and submit it at this stage yields no response. This is because we haven't registered the form entries, and we haven't employed the onSubmit
function to handle the submission event.
The inputs
One of the key concepts of React Hook Form is to register your component in the hook. This will make its value available for both validation and form submission.
Note: Each field must have a name as a key for the registration process.
We'll use the register function we get from the useForm hook to register the form entries, giving them the same name as the one we supplied to the validationSchema.
For the firstname field, we'll register it as follows:
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow- outline"
id="firstName"
type="text"
placeholder="First Name"
{...register("firstName")}
/>
As well for email field:
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Email"
{...register("email")}
/>
etc...
The form submission
We've already added the custom handle function onSubmit. All we need to do is pass the onSubmit function to the handleSubmit function as an argument and the rest is taken care of by it.
<form className="px-8 pt-6 pb-8 mb-4" onSubmit= {handleSubmit(onSubmit)}>
We get the handleSubmit method from the useForm hook, which means that handleSubmit will validate inputs before invoking the custom onSubmit method. Note that we don't need to prevent default behaviour as we normally would, that's done by the handleSubmit method.
Now, if you try to fill in the form with valid input and submit it, the value is stored in the console successfully. However, if you try to submit with invalid values or with an empty form, nothing happens. We will have to conditionally display the error message for each field if it is invalid.
Handle errors
React Hook Form offers an errors object to facilitate the display of errors in the form. The errors type corresponds to the specified validation constraints. To access this information, we can destructure the formState to retrieve the errors object.
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ValidationSchema>({
resolver: zodResolver(validationSchema),
});
Now, we can implement the conditional display of error messages for each field using the errors object. Specifically, for the firstName field:
<input
className="w-full px-3 py-2 text-sm leading-tight text-gray-700 border rounded appearance-none focus:outline-none focus:shadow-outline"
id="firstName"
type="text"
{...register("firstName")}
/>
{errors.firstName && (
<p className="text-xs italic text-red-500 mt-2"> {errors.firstName?.message}
</p>
)}
For the rest of the fields you can compare them below:
<form className="px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="firstName"
>
First Name
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.firstName && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="firstName"
type="text"
placeholder="First Name"
{...register("firstName")}
/>
{errors.firstName && (
<p className="text-xs italic text-red-500 mt-2">
{errors.firstName?.message}
</p>
)}
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="lastName"
>
Last Name
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.lastName && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="lastName"
type="text"
placeholder="Last Name"
{...register("lastName")}
/>
{errors.lastName && (
<p className="text-xs italic text-red-500 mt-2">
{errors.lastName?.message}
</p>
)}
</div>
</div>
<div className="mb-4">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="email"
>
Email
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.email && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="email"
type="email"
placeholder="Email"
{...register("email")}
/>
{errors.email && (
<p className="text-xs italic text-red-500 mt-2">
{errors.email?.message}
</p>
)}
</div>
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="password"
>
Password
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.password && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="password"
type="password"
{...register("password")}
/>
{errors.password && (
<p className="text-xs italic text-red-500 mt-2">
{errors.password?.message}
</p>
)}
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="c_password"
>
Confirm Password
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.confirmPassword && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="c_password"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="text-xs italic text-red-500 mt-2">
{errors.confirmPassword?.message}
</p>
)}
</div>
</div>
<div className="mb-4">
<input type="checkbox" id="terms" {...register("terms")} />
<label
htmlFor="terms"
className={`ml-2 mb-2 text-sm font-bold ${
errors.terms ? "text-red-500" : "text-gray-700"
}`}
>
Accept Terms & Conditions
</label>
{errors.terms && (
<p className="text-xs italic text-red-500 mt-2">
{errors.terms?.message}
</p>
)}
</div>
<div className="mb-6 text-center">
<button
className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
type="submit"
>
Register Account
</button>
</div>
<hr className="mb-6 border-t" />
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="#test"
>
Forgot Password?
</a>
</div>
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="./index.html"
>
Already have an account? Login!
</a>
</div>
</form>
Additionally, I've incorporated ${errors.firstName && "border-red-500"}
in the className to dynamically highlight the input with a red border when it's invalid. Now, if you attempt to submit the form with invalid inputs, you should observe the error messages appearing in their corresponding fields.
To recap the progress made in the Form.tsx file thus far:
import { SubmitHandler, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const validationSchema = z
.object({
firstName: z.string().min(1, { message: "Firstname is required" }),
lastName: z.string().min(1, { message: "Lastname is required" }),
email: z.string().min(1, { message: "Email is required" }).email({
message: "Must be a valid email",
}),
password: z
.string()
.min(6, { message: "Password must be atleast 6 characters" }),
confirmPassword: z
.string()
.min(1, { message: "Confirm Password is required" }),
terms: z.literal(true, {
errorMap: () => ({ message: "You must accept Terms and Conditions" }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Password don't match",
});
type ValidationSchema = z.infer<typeof validationSchema>;
const Form = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ValidationSchema>({
resolver: zodResolver(validationSchema),
});
const onSubmit: SubmitHandler<ValidationSchema> = (data) => console.log(data);
return (
<form className="px-8 pt-6 pb-8 mb-4" onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="firstName"
>
First Name
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.firstName && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="firstName"
type="text"
placeholder="First Name"
{...register("firstName")}
/>
{errors.firstName && (
<p className="text-xs italic text-red-500 mt-2">
{errors.firstName?.message}
</p>
)}
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="lastName"
>
Last Name
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.lastName && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="lastName"
type="text"
placeholder="Last Name"
{...register("lastName")}
/>
{errors.lastName && (
<p className="text-xs italic text-red-500 mt-2">
{errors.lastName?.message}
</p>
)}
</div>
</div>
<div className="mb-4">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="email"
>
Email
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.email && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="email"
type="email"
placeholder="Email"
{...register("email")}
/>
{errors.email && (
<p className="text-xs italic text-red-500 mt-2">
{errors.email?.message}
</p>
)}
</div>
<div className="mb-4 md:flex md:justify-between">
<div className="mb-4 md:mr-2 md:mb-0">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="password"
>
Password
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.password && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="password"
type="password"
{...register("password")}
/>
{errors.password && (
<p className="text-xs italic text-red-500 mt-2">
{errors.password?.message}
</p>
)}
</div>
<div className="md:ml-2">
<label
className="block mb-2 text-sm font-bold text-gray-700"
htmlFor="c_password"
>
Confirm Password
</label>
<input
className={`w-full px-3 py-2 text-sm leading-tight text-gray-700 border ${
errors.confirmPassword && "border-red-500"
} rounded appearance-none focus:outline-none focus:shadow-outline`}
id="c_password"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<p className="text-xs italic text-red-500 mt-2">
{errors.confirmPassword?.message}
</p>
)}
</div>
</div>
<div className="mb-4">
<input type="checkbox" id="terms" {...register("terms")} />
<label
htmlFor="terms"
className={`ml-2 mb-2 text-sm font-bold ${
errors.terms ? "text-red-500" : "text-gray-700"
}`}
>
Accept Terms & Conditions
</label>
{errors.terms && (
<p className="text-xs italic text-red-500 mt-2">
{errors.terms?.message}
</p>
)}
</div>
<div className="mb-6 text-center">
<button
className="w-full px-4 py-2 font-bold text-white bg-blue-500 rounded-full hover:bg-blue-700 focus:outline-none focus:shadow-outline"
type="submit"
>
Register Account
</button>
</div>
<hr className="mb-6 border-t" />
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="#test"
>
Forgot Password?
</a>
</div>
<div className="text-center">
<a
className="inline-block text-sm text-blue-500 align-baseline hover:text-blue-800"
href="./index.html"
>
Already have an account? Login!
</a>
</div>
</form>
);
};
export default Form;
Conclusion
In this article, we explored how to use Zod as a validation library with React Hook Form. We delved into creating a schema and utilizing it to infer types for our form. I aimed to cover the fundamentals of both libraries and how they can seamlessly integrate with each other.