Form Validation with React Hook Form

Updated on · 7 min read
Form Validation with React Hook Form

User interaction is at the heart of a responsive and engaging user experience. Forms are a vital part of this interaction, allowing users to submit information necessary to perform a bunch of important actions online. Ensuring that the information submitted through these forms is accurate and valid is an essential task, not only for a seamless user experience but also for data integrity and security.

This is where form validation comes into play. Form validation is the process of checking the entered data against specific criteria before it is processed. This can include checking that a required field has been filled out, that an email address is in the correct format, or that a password meets specific complexity requirements.

Among the numerous tools and libraries available for form validation in React, React Hook Form stands out as a robust, easy-to-use solution. Designed to minimize re-renders and optimize performance, React Hook Form utilizes hooks to help developers create even complex forms with ease.

In this post we'll explore the capabilities of the React Hook Form, when it comes to form validation. We'll build a simple recipe form with React and TypeScript, similar to the one from an earlier post, and see how React Hook Form can help us validate the form inputs and display error messages.

Managing form validation challenges with React Hook Form

While form validation is crucial, it can often be tedious and error-prone, especially when dealing with complex forms and validation rules such as dynamic or multistep forms. Some common challenges include:

  • Implementing consistent validation across different parts of an application.
  • Handling various input types and complex validation logic.
  • Providing clear and user-friendly error messages.
  • Balancing client-side and server-side validation.

In the crowded space of form validation libraries, React Hook Form emerges as a powerful and efficient solution to these challenges. Here’s why:

  • Simplicity: With an intuitive API, React Hook Form makes it easy to integrate and manage form validations.
  • Performance: Optimized to minimize re-renders, it provides a smooth user experience even with complex forms.
  • Flexibility: Whether you need built-in validations or custom logic, React Hook Form offers versatility to meet various validation needs.
  • Community and Support: A thriving community and comprehensive documentation make it accessible for developers of all skill levels.

Getting Started

Let's get started by setting up our components. We'll be using Create React App to set up our project. With it, we can create a new project by running:

bash
npx create-react-app react-hook-form-validation --template typescript
bash
npx create-react-app react-hook-form-validation --template typescript

Next, we'll install React Hook Form itself:

bash
npm install react-hook-form
bash
npm install react-hook-form

Setting up the form

Now we can create the RecipeForm component and add React Hook Form logic to it. This component will be responsible for rendering the form and handling the form submission.

One of the key benefits of React Hook Form is that it simplifies state management, eliminating the need for multiple useState hooks. All we have to do is use the register function with the field's name as an argument to register fields within the form's state.

tsx
// RecipeForm.tsx import { useForm } from "react-hook-form"; import { FieldSet } from "./FieldSet"; import { Field as Field } from "./Field"; interface FormData { name: string; description: string; amount: number; } export const RecipeForm = () => { const { register, handleSubmit } = useForm<FormData>(); const submitForm = (formData: FormData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name"> <input {...register("name")} type="text" id="name" /> </Field> <Field label="Description"> <textarea {...register("description")} id="description" rows={10} /> </Field> <Field label="Servings"> <input {...register("amount")} type="number" id="amount" /> </Field> </FieldSet> <Field> <button>Save</button> </Field> </form> </div> ); };
tsx
// RecipeForm.tsx import { useForm } from "react-hook-form"; import { FieldSet } from "./FieldSet"; import { Field as Field } from "./Field"; interface FormData { name: string; description: string; amount: number; } export const RecipeForm = () => { const { register, handleSubmit } = useForm<FormData>(); const submitForm = (formData: FormData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name"> <input {...register("name")} type="text" id="name" /> </Field> <Field label="Description"> <textarea {...register("description")} id="description" rows={10} /> </Field> <Field label="Servings"> <input {...register("amount")} type="number" id="amount" /> </Field> </FieldSet> <Field> <button>Save</button> </Field> </form> </div> ); };

We begin by importing and invoking the useForm hook, which returns several helpful utilities. In this instance, we use register to associate a form field with its corresponding property in the state via the field's name. This is why adding names to the fields is crucial here. Additionally, we need to wrap our submit function in the handleSubmit callback.

Now, if we enter recipe details in the form fields and press Save, we should see the following object displayed in the console:

json
{ "name": "Pancakes", "description": "Super delicious pancake recipe", "amount": "10" }
json
{ "name": "Pancakes", "description": "Super delicious pancake recipe", "amount": "10" }

You may notice that we have added a few of the custom utility components, namely Field and FieldSet. These components handle the repeated UI logic and look like this:

tsx
// Field.tsx import { Children, ReactElement } from "react"; interface FieldProps { label?: string; htmlFor?: string; error?: string; children: ReactElement; } export const Field = ({ label, htmlFor, error, children }: FieldProps) => { const id = htmlFor || getChildId(children); return ( <div className="form-field"> {label && <label htmlFor={id}>{label}</label>} {children} {error && ( <div role={"alert"} className="error"> {error} </div> )} </div> ); }; function getChildId(children: ReactElement) { const child = Children.only(children); if ("id" in child?.props) { return child.props.id; } }
tsx
// Field.tsx import { Children, ReactElement } from "react"; interface FieldProps { label?: string; htmlFor?: string; error?: string; children: ReactElement; } export const Field = ({ label, htmlFor, error, children }: FieldProps) => { const id = htmlFor || getChildId(children); return ( <div className="form-field"> {label && <label htmlFor={id}>{label}</label>} {children} {error && ( <div role={"alert"} className="error"> {error} </div> )} </div> ); }; function getChildId(children: ReactElement) { const child = Children.only(children); if ("id" in child?.props) { return child.props.id; } }
tsx
// FieldSet.tsx import { ReactElement } from "react"; interface FieldSetProps { label?: string; children: ReactElement[]; } export const FieldSet = ({ label, children }: FieldSetProps) => { return ( <fieldset> {label && <legend>{label}</legend>} <div>{children}</div> </fieldset> ); };
tsx
// FieldSet.tsx import { ReactElement } from "react"; interface FieldSetProps { label?: string; children: ReactElement[]; } export const FieldSet = ({ label, children }: FieldSetProps) => { return ( <fieldset> {label && <legend>{label}</legend>} <div>{children}</div> </fieldset> ); };

In particular, the Field component is responsible for rendering the label, input, and error message. The FieldSet component is used to group related fields together. This way, we can keep the RecipeForm component clean and focused on the form logic, while also improving the overall accessibility and maintainability of the form.

Adding form validation

Now that we have the form state managed by React Hook Form, we can add validation to the form. One of the selling points of React Hook Form is the ease of validation. It provides a register function that accepts a validation object as a second argument. This object contains the validation rules for each field.

Several validation rules are available:

  • required - will be triggered if the field is empty
  • min - will be triggered if the field value is less than the specified value
  • max - will be triggered if the field value is greater than the specified value
  • minLength - will be triggered if the field value is shorter than the specified length
  • maxLength - will be triggered if the field value is longer than the specified length
  • pattern - will be triggered if the field value does not match the specified pattern
  • validate - provides custom validation logic

Let's see a few of these rules in action by applying them to the RecipeForm component.

tsx
// RecipeForm.tsx import { useForm } from "react-hook-form"; import { FieldSet } from "./FieldSet"; import { Field as Field } from "./Field"; interface FormData { name: string; description: string; amount: number; } export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>(); const submitForm = (formData: FormData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name?.message}> <input {...register("name", { required: "Recipe name is required", minLength: { value: 3, message: "Recipe name must be at least 3 characters long", }, })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description?.message}> <textarea {...register("description", { maxLength: { value: 100, message: "Description must be at most 100 characters long", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { min: { value: 1, message: "Amount of servings has to be at least 1", }, max: { value: 10, message: "A recipe can have max 10 servings", }, })} type="number" id="amount" /> </Field> </FieldSet> <Field> <button>Save</button> </Field> </form> </div> ); };
tsx
// RecipeForm.tsx import { useForm } from "react-hook-form"; import { FieldSet } from "./FieldSet"; import { Field as Field } from "./Field"; interface FormData { name: string; description: string; amount: number; } export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>(); const submitForm = (formData: FormData) => { console.log(formData); }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name?.message}> <input {...register("name", { required: "Recipe name is required", minLength: { value: 3, message: "Recipe name must be at least 3 characters long", }, })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description?.message}> <textarea {...register("description", { maxLength: { value: 100, message: "Description must be at most 100 characters long", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { min: { value: 1, message: "Amount of servings has to be at least 1", }, max: { value: 10, message: "A recipe can have max 10 servings", }, })} type="number" id="amount" /> </Field> </FieldSet> <Field> <button>Save</button> </Field> </form> </div> ); };

Here we add the formState object to the useForm hook's return value. This object contains the errors property, which we can use to display validation errors. We also add the error prop to the Field component, which we can use to display the error message. Finally, we add the validation rules to the register function's second argument. Note that we can provide a custom error message for each rule. We're validating that the recipe name is present, is at least 3 characters long, the description is at most 100 characters long, and the amount of servings is between 1 and 10. For the required validation we can specify the error message directly as a value of the required property. For the other rules, we need to provide an object with the value and message properties.

If we try to submit the form with invalid data, we should see the validation errors displayed below the corresponding fields.

Custom validation methods

In addition to the built-in validation rules, we can also provide custom validation logic. For example, we can validate that the amount of servings is always even. To do this, we need to provide a custom validation method for the register function. This method should return true if the field is valid and false otherwise.

tsx
// RecipeForm.tsx <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { validate: (value) => value % 2 === 0 || "The number of servings must be an even number", })} type="number" id="amount" /> </Field>
tsx
// RecipeForm.tsx <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { validate: (value) => value % 2 === 0 || "The number of servings must be an even number", })} type="number" id="amount" /> </Field>

Here we add the validate property to the register function's second argument. Instead of returning true if the field is valid and false otherwise, we can also provide a custom error message as the return value of the function.

Asynchronous validation

We can also use the register function's second argument to perform asynchronous validation. For example, we can validate that the recipe name is unique. To do this, we need to provide a promise as the value of the validate property. This promise should resolve to true if the field is valid and false otherwise. Alternatively, we can provide a custom error message as the promise's rejection value.

tsx
// RecipeForm.tsx // RecipeForm.tsx import { useForm } from "react-hook-form"; import { FieldSet } from "./FieldSet"; import { Field as Field } from "./Field"; interface FormData { name: string; description: string; amount: number; ingredients: string[]; } export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>(); const submitForm = (formData: FormData) => { console.log(formData); }; const validateName = async (value: string) => { await new Promise((resolve) => setTimeout(resolve, 2000)); return value !== "Pizza" || "Recipe with the name 'Pizza' already exists"; }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name?.message}> <input {...register("name", { required: "Recipe name is required", minLength: { value: 3, message: "Recipe name must be at least 3 characters long", }, validate: validateName, })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description?.message}> <textarea {...register("description", { maxLength: { value: 100, message: "Description must be at most 100 characters long", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { validate: (value) => value % 2 === 0 || "The number of servings must be an even number", })} type="number" id="amount" /> </Field> </FieldSet> <Field> <button>Save</button> </Field> </form> </div> ); };
tsx
// RecipeForm.tsx // RecipeForm.tsx import { useForm } from "react-hook-form"; import { FieldSet } from "./FieldSet"; import { Field as Field } from "./Field"; interface FormData { name: string; description: string; amount: number; ingredients: string[]; } export const RecipeForm = () => { const { register, handleSubmit, formState: { errors }, } = useForm<FormData>(); const submitForm = (formData: FormData) => { console.log(formData); }; const validateName = async (value: string) => { await new Promise((resolve) => setTimeout(resolve, 2000)); return value !== "Pizza" || "Recipe with the name 'Pizza' already exists"; }; return ( <div> <h1>New recipe</h1> <form onSubmit={handleSubmit(submitForm)}> <FieldSet label="Basics"> <Field label="Name" error={errors.name?.message}> <input {...register("name", { required: "Recipe name is required", minLength: { value: 3, message: "Recipe name must be at least 3 characters long", }, validate: validateName, })} type="text" id="name" /> </Field> <Field label="Description" error={errors.description?.message}> <textarea {...register("description", { maxLength: { value: 100, message: "Description must be at most 100 characters long", }, })} id="description" rows={10} /> </Field> <Field label="Servings" error={errors.amount?.message}> <input {...register("amount", { validate: (value) => value % 2 === 0 || "The number of servings must be an even number", })} type="number" id="amount" /> </Field> </FieldSet> <Field> <button>Save</button> </Field> </form> </div> ); };

Here we add the validateName function, which returns a promise that resolves to an error message if the value is not "Pizza". We then add this function to the register function's second argument as the value of the validate property. Now if we enter "Pizza" as the recipe name, we should see the validation error displayed below the field. Note that we need to use the Promise constructor and setTimeout to simulate an asynchronous operation that takes some time to complete. In a real-world application, we would use an API call instead.

Conclusion

Form validation is an essential, but often complex and tedious task in web development. The common challenges such as ensuring consistent validation, handling various input types, and providing clear error messages can be daunting. However, React Hook Form presents a highly efficient solution, offering simplicity, performance, flexibility, and great community support. Through the use of its intuitive API and functionalities like built-in and custom validation rules, as well as synchronous and asynchronous validation options, developers can construct robust forms without the usual headaches. By empowering developers to handle both simple and complex validation requirements with ease, React Hook Form stands out in the crowded space of form validation libraries.

References and resources