Skip to main content

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.

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 useForm hook 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.

form.tsx
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."),
})
form.tsx
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.

form.tsx
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>
  )
}
form.tsx
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.

form-rhf-demo.tsx
// form-rhf-demo.tsx
'use client'
 
import { Button } from '@gentleduck/registry-ui/button'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@gentleduck/registry-ui/field'
import { Input } from '@gentleduck/registry-ui/input'
import { Textarea } from '@gentleduck/registry-ui/textarea'
import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
 
const formSchema = z.object({
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters.')
    .max(100, 'Description must be at most 100 characters.'),
  title: z
    .string()
    .min(5, 'Bug title must be at least 5 characters.')
    .max(32, 'Bug title must be at most 32 characters.'),
})
 
type FormValues = z.infer<typeof formSchema>
 
export default function FormRHFDemo() {
  const form = useForm<FormValues>({
    defaultValues: {
      description: '',
      title: '',
    },
    resolver: zodResolver(formSchema),
  })
 
  function onSubmit(values: FormValues) {
    toast.success('Form submitted successfully', {
      description: (
        <pre className="mt-2 max-w-[520px] overflow-x-auto rounded-md border bg-muted p-4 text-xs">
          {JSON.stringify(values, null, 2)}
        </pre>
      ),
    })
  }
 
  return (
    <form className="w-full max-w-xl space-y-6" noValidate onSubmit={form.handleSubmit(onSubmit)}>
      <FieldGroup>
        <Controller
          control={form.control}
          name="title"
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
              <Input
                {...field}
                id={field.name}
                aria-invalid={fieldState.invalid}
                autoComplete="off"
                placeholder="Login button not working on mobile"
              />
              <FieldDescription>Provide a concise title for your bug report.</FieldDescription>
              {fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
            </Field>
          )}
        />
 
        <Controller
          control={form.control}
          name="description"
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor={field.name}>Description</FieldLabel>
              <Textarea
                {...field}
                id={field.name}
                aria-invalid={fieldState.invalid}
                className="min-h-[120px]"
                placeholder="Describe the issue and expected behavior..."
              />
              <FieldDescription>Add enough detail for someone else to reproduce the bug.</FieldDescription>
              {fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
            </Field>
          )}
        />
      </FieldGroup>
 
      <Button type="submit">Submit</Button>
    </form>
  )
}
// form-rhf-demo.tsx
'use client'
 
import { Button } from '@gentleduck/registry-ui/button'
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@gentleduck/registry-ui/field'
import { Input } from '@gentleduck/registry-ui/input'
import { Textarea } from '@gentleduck/registry-ui/textarea'
import { zodResolver } from '@hookform/resolvers/zod'
import { Controller, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
 
const formSchema = z.object({
  description: z
    .string()
    .min(20, 'Description must be at least 20 characters.')
    .max(100, 'Description must be at most 100 characters.'),
  title: z
    .string()
    .min(5, 'Bug title must be at least 5 characters.')
    .max(32, 'Bug title must be at most 32 characters.'),
})
 
type FormValues = z.infer<typeof formSchema>
 
export default function FormRHFDemo() {
  const form = useForm<FormValues>({
    defaultValues: {
      description: '',
      title: '',
    },
    resolver: zodResolver(formSchema),
  })
 
  function onSubmit(values: FormValues) {
    toast.success('Form submitted successfully', {
      description: (
        <pre className="mt-2 max-w-[520px] overflow-x-auto rounded-md border bg-muted p-4 text-xs">
          {JSON.stringify(values, null, 2)}
        </pre>
      ),
    })
  }
 
  return (
    <form className="w-full max-w-xl space-y-6" noValidate onSubmit={form.handleSubmit(onSubmit)}>
      <FieldGroup>
        <Controller
          control={form.control}
          name="title"
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor={field.name}>Bug Title</FieldLabel>
              <Input
                {...field}
                id={field.name}
                aria-invalid={fieldState.invalid}
                autoComplete="off"
                placeholder="Login button not working on mobile"
              />
              <FieldDescription>Provide a concise title for your bug report.</FieldDescription>
              {fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
            </Field>
          )}
        />
 
        <Controller
          control={form.control}
          name="description"
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor={field.name}>Description</FieldLabel>
              <Textarea
                {...field}
                id={field.name}
                aria-invalid={fieldState.invalid}
                className="min-h-[120px]"
                placeholder="Describe the issue and expected behavior..."
              />
              <FieldDescription>Add enough detail for someone else to reproduce the bug.</FieldDescription>
              {fieldState.invalid && fieldState.error && <FieldError errors={[fieldState.error]} />}
            </Field>
          )}
        />
      </FieldGroup>
 
      <Button type="submit">Submit</Button>
    </form>
  )
}

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.

example-form.tsx
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: "",
    },
  })
}
example-form.tsx
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.

form.tsx
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  mode: "onChange",
})
form.tsx
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  mode: "onChange",
})
ModeDescription
"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-invalid prop to the <Field /> component.
  • Add the aria-invalid prop to the form control such as <Input />, <SelectTrigger />, <Checkbox />, etc.
form.tsx
<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>
  )}
/>
form.tsx
<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 field object onto the <Input /> component.
  • To show errors, add the aria-invalid prop to the <Input /> component and the data-invalid prop to the <Field /> component.

Textarea

  • For textarea fields, spread the field object onto the <Textarea /> component.
  • To show errors, add the aria-invalid prop to the <Textarea /> component and the data-invalid prop to the <Field /> component.

Select

  • For select components, use field.value and field.onChange on the <Select /> component.
  • To show errors, add the aria-invalid prop to the <SelectTrigger /> component and the data-invalid prop to the <Field /> component.

Checkbox

  • For checkbox arrays, use field.value and field.onChange with array manipulation.
  • To show errors, add the aria-invalid prop to the <Checkbox /> component and the data-invalid prop 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.value and field.onChange on the <RadioGroup /> component.
  • To show errors, add the aria-invalid prop to the <RadioGroupItem /> component and the data-invalid prop to the <Field /> component.

Switch

  • For switches, use field.value and field.onChange on the <Switch /> component.
  • To show errors, add the aria-invalid prop to the <Switch /> component and the data-invalid prop 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.

form.tsx
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",
  })
}
form.tsx
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.

form.tsx
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."),
})
form.tsx
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."),
})