React Hook Form
Build forms in React using React Hook Form and Zod.
In this guide, we will take a look at building forms with React Hook Form. We'll cover building forms with the <Field /> component, adding schema validation using Zod, error handling, accessibility, and more.
Demo
We are going to build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.
Note: For the purpose of this demo, we have intentionally disabled browser validation to show how schema validation and form errors work in React Hook Form. It is recommended to add basic browser validation in your production code.
Approach
This form leverages React Hook Form for performant, flexible form handling. We'll build our form using the <Field /> component, which gives you complete flexibility over the markup and styling.
- Uses React Hook Form's
useFormhook for form state management. <Controller />component for controlled inputs.<Field />components for building accessible forms.- Client-side validation using Zod with
zodResolver.
Anatomy
Here's a basic example of a form using the <Controller /> component from React Hook Form and the <Field /> component.
<Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/><Controller
name="title"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
<Input
{...field}
id={field.name}
aria-invalid={fieldState.invalid}
placeholder="Login button not working on mobile"
autoComplete="off"
/>
<FieldDescription>
Provide a concise title for your bug report.
</FieldDescription>
{fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Form
Create a form schema
We'll start by defining the shape of our form using a Zod schema.
import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})Setup the form
Next, we'll use the useForm hook from React Hook Form to create our form instance. We'll also add the Zod resolver to validate the form data.
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})
export function BugReportForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
console.log(data)
}
return (
<form noValidate onSubmit={form.handleSubmit(onSubmit)}>
{/* Build the form here */}
</form>
)
}import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(32, "Bug title must be at most 32 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(100, "Description must be at most 100 characters."),
})
export function BugReportForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
console.log(data)
}
return (
<form noValidate onSubmit={form.handleSubmit(onSubmit)}>
{/* Build the form here */}
</form>
)
}Build the form
We can now build the form using the <Controller /> component from React Hook Form and the <Field /> component.
Done
That's it. You now have a fully accessible form with client-side validation.
When you submit the form, the onSubmit function will be called with the validated form data. If the form data is invalid, React Hook Form will display the errors next to each field.
Validation
Client-side Validation
React Hook Form validates your form data using the Zod schema. Define a schema and pass it to the resolver option of the useForm hook.
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
const formSchema = z.object({
title: z.string(),
description: z.string().optional(),
})
export function ExampleForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
}import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
const formSchema = z.object({
title: z.string(),
description: z.string().optional(),
})
export function ExampleForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
},
})
}Validation Modes
React Hook Form supports different validation modes.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
})const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
})| Mode | Description |
|---|---|
"onChange" | Validation triggers on every change. |
"onBlur" | Validation triggers on blur. |
"onSubmit" | Validation triggers on submit (default). |
"onTouched" | Validation triggers on first blur, then on every change. |
"all" | Validation triggers on blur and change. |
Displaying Errors
Display errors next to the field using <FieldError />. For styling and accessibility:
- Add the
data-invalidprop to the<Field />component. - Add the
aria-invalidprop to the form control such as<Input />,<SelectTrigger />,<Checkbox />, etc.
<Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/><Controller
name="email"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Email</FieldLabel>
<Input
{...field}
id={field.name}
type="email"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>Working with Different Field Types
Input
- For input fields, spread the
fieldobject onto the<Input />component. - To show errors, add the
aria-invalidprop to the<Input />component and thedata-invalidprop to the<Field />component.
Textarea
- For textarea fields, spread the
fieldobject onto the<Textarea />component. - To show errors, add the
aria-invalidprop to the<Textarea />component and thedata-invalidprop to the<Field />component.
Select
- For select components, use
field.valueandfield.onChangeon the<Select />component. - To show errors, add the
aria-invalidprop to the<SelectTrigger />component and thedata-invalidprop to the<Field />component.
Checkbox
- For checkbox arrays, use
field.valueandfield.onChangewith array manipulation. - To show errors, add the
aria-invalidprop to the<Checkbox />component and thedata-invalidprop to the<Field />component. - Remember to add
data-slot="checkbox-group"to the<FieldGroup />component for proper styling and spacing.
Radio Group
- For radio groups, use
field.valueandfield.onChangeon the<RadioGroup />component. - To show errors, add the
aria-invalidprop to the<RadioGroupItem />component and thedata-invalidprop to the<Field />component.
Switch
- For switches, use
field.valueandfield.onChangeon the<Switch />component. - To show errors, add the
aria-invalidprop to the<Switch />component and thedata-invalidprop to the<Field />component.
Complex Forms
Here is an example of a more complex form with multiple fields and validation.
Resetting the Form
Use form.reset() to reset the form to its default values.
<Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button><Button type="button" variant="outline" onClick={() => form.reset()}>
Reset
</Button>Array Fields
React Hook Form provides a useFieldArray hook for managing dynamic array fields. This is useful when you need to add or remove fields dynamically.
Using useFieldArray
Use the useFieldArray hook to manage array fields. It provides fields, append, and remove methods.
import { useFieldArray, useForm } from "react-hook-form"
export function ExampleForm() {
const form = useForm({
// ... form config
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "emails",
})
}import { useFieldArray, useForm } from "react-hook-form"
export function ExampleForm() {
const form = useForm({
// ... form config
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "emails",
})
}Array Validation
Use Zod's array method to validate array fields.
const formSchema = z.object({
emails: z
.array(
z.object({
address: z.string().email("Enter a valid email address."),
})
)
.min(1, "Add at least one email address.")
.max(5, "You can add up to 5 email addresses."),
})const formSchema = z.object({
emails: z
.array(
z.object({
address: z.string().email("Enter a valid email address."),
})
)
.min(1, "Add at least one email address.")
.max(5, "You can add up to 5 email addresses."),
})