React Hook Form

Build forms with React Hook Form, Zod and the Field component.

This guide builds a form with React Hook Form, validates it with a Zod schema and shows each field's error with Field.

Demo

The form has a title and a description. On submit it validates both and shows the values it accepted.

Bug Report
Help us improve by reporting bugs you encounter.
0/100

Include steps to reproduce, expected behavior, and what actually happened.

"use client"

import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import * as stylex from "@stylexjs/stylex"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

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

type FormValues = z.infer<typeof formSchema>

const styles = stylex.create({
  card: { width: "100%", maxWidth: "28rem" },
  output: {
    marginBlockStart: space.s4,
    overflowX: "auto",
    borderRadius: radius.md,
    backgroundColor: color.muted,
    padding: space.s3,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
  },
})

export default function FormRhfDemo() {
  const [submitted, setSubmitted] = useState<FormValues | null>(null)
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { title: "", description: "" },
  })

  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Bug Report</CardTitle>
        <CardDescription>Help us improve by reporting bugs you encounter.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-demo" onSubmit={form.handleSubmit((data) => setSubmitted(data))}>
          <FieldGroup>
            <Controller
              name="title"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Bug Title</FieldLabel>
                  <Input
                    {...field}
                    id="form-rhf-demo-title"
                    placeholder="Login button not working on mobile"
                    autoComplete="off"
                  />
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <Controller
              name="description"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Description</FieldLabel>
                  <InputGroup>
                    <Input
                      {...field}
                      id="form-rhf-demo-description"
                      placeholder="I'm having an issue with the login button on mobile."
                      autoComplete="off"
                    />
                    <InputGroupAddon align="inline-end">
                      <InputGroupText>{field.value.length}/100</InputGroupText>
                    </InputGroupAddon>
                  </InputGroup>
                  <FieldDescription>
                    Include steps to reproduce, expected behavior, and what actually happened.
                  </FieldDescription>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
          </FieldGroup>
        </form>
        {submitted && (
          <pre {...stylex.props(styles.output)}>{JSON.stringify(submitted, null, 2)}</pre>
        )}
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset()
              setSubmitted(null)
            }}
          >
            Reset
          </Button>
          <Button type="submit" form="form-rhf-demo">
            Submit
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

The demo has no browser validation, so the schema errors are the only ones you see. Add required, minLength and the other native attributes in production. They cost nothing and work without JavaScript.

Installation

pnpm add react-hook-form @hookform/resolvers zod

The examples also use Field, Input, Checkbox, Button and Card.

Approach

  • useForm holds the form state.
  • Controller connects one control to it and reports that control's state.
  • Field lays out the label, control, description and error.
  • zodResolver runs the schema and maps its issues onto the fields.

Anatomy

Every field in this guide has the same shape.

<Controller
  name="title"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldLabel>Bug Title</FieldLabel>
      <Input {...field} placeholder="Login button not working on mobile" autoComplete="off" />
      <FieldDescription>Provide a concise title for your bug report.</FieldDescription>
      <FieldError errors={[fieldState.error]} />
    </Field>
  )}
/>

Pass fieldState.invalid to Field once. The label, error and control inherit it. FieldLabel associates with the control automatically. FieldError renders nothing while fieldState.error is undefined, so it needs no condition around it.

Form

Create a form schema

Define the shape of the form with Zod.

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

Set up the form

Create the form with useForm and pass it the schema through zodResolver. Every field needs a default value, or its control starts uncontrolled and switches later.

form.tsx
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"

type FormValues = z.infer<typeof formSchema>

export function BugReportForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { title: "", description: "" },
  })

  function onSubmit(data: FormValues) {
    console.log(data)
  }

  return <form onSubmit={form.handleSubmit(onSubmit)}>{/* Build the form here */}</form>
}

Build the form

Add one Controller per field, each rendering a Field.

form.tsx
"use client"

import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import * as stylex from "@stylexjs/stylex"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

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

type FormValues = z.infer<typeof formSchema>

const styles = stylex.create({
  card: { width: "100%", maxWidth: "28rem" },
  output: {
    marginBlockStart: space.s4,
    overflowX: "auto",
    borderRadius: radius.md,
    backgroundColor: color.muted,
    padding: space.s3,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
  },
})

export default function FormRhfDemo() {
  const [submitted, setSubmitted] = useState<FormValues | null>(null)
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { title: "", description: "" },
  })

  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Bug Report</CardTitle>
        <CardDescription>Help us improve by reporting bugs you encounter.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-demo" onSubmit={form.handleSubmit((data) => setSubmitted(data))}>
          <FieldGroup>
            <Controller
              name="title"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Bug Title</FieldLabel>
                  <Input
                    {...field}
                    id="form-rhf-demo-title"
                    placeholder="Login button not working on mobile"
                    autoComplete="off"
                  />
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <Controller
              name="description"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Description</FieldLabel>
                  <InputGroup>
                    <Input
                      {...field}
                      id="form-rhf-demo-description"
                      placeholder="I'm having an issue with the login button on mobile."
                      autoComplete="off"
                    />
                    <InputGroupAddon align="inline-end">
                      <InputGroupText>{field.value.length}/100</InputGroupText>
                    </InputGroupAddon>
                  </InputGroup>
                  <FieldDescription>
                    Include steps to reproduce, expected behavior, and what actually happened.
                  </FieldDescription>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
          </FieldGroup>
        </form>
        {submitted && (
          <pre {...stylex.props(styles.output)}>{JSON.stringify(submitted, null, 2)}</pre>
        )}
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset()
              setSubmitted(null)
            }}
          >
            Reset
          </Button>
          <Button type="submit" form="form-rhf-demo">
            Submit
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

Done

handleSubmit calls onSubmit with the parsed values only when the schema passes. When it fails, React Hook Form marks the fields and FieldError shows each message.

Validation

Client-side validation

Pass the schema to the resolver option. Zod's messages become the fieldState.error each Controller receives.

example-form.tsx
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  defaultValues: { title: "", description: "" },
})

Validation modes

Use mode to choose when validation runs.

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

Render FieldError with errors={[fieldState.error]} next to the control. Set invalid on Field. The control inherits it.

form.tsx
<Controller
  name="email"
  control={form.control}
  render={({ field, fieldState }) => (
    <Field invalid={fieldState.invalid}>
      <FieldLabel>Email</FieldLabel>
      <Input {...field} id={field.name} type="email" />
      <FieldError errors={[fieldState.error]} />
    </Field>
  )}
/>

Working with different field types

Use the same Field wrapper for Textarea, Select, RadioGroup and Switch. Connect field.value and field.onChange to each control's value and change props.

Input

Spread field onto the Input. It carries name, value, onChange, onBlur and ref.

Profile Settings
Update your profile information below.

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

"use client"

import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import * as stylex from "@stylexjs/stylex"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

const formSchema = z.object({
  username: z
    .string()
    .min(3, "Username must be at least 3 characters.")
    .max(10, "Username must be at most 10 characters.")
    .regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores."),
})

type FormValues = z.infer<typeof formSchema>

const styles = stylex.create({
  card: { width: "100%", maxWidth: "28rem" },
  output: {
    marginBlockStart: space.s4,
    overflowX: "auto",
    borderRadius: radius.md,
    backgroundColor: color.muted,
    padding: space.s3,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
  },
})

export default function FormRhfInput() {
  const [submitted, setSubmitted] = useState<FormValues | null>(null)
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { username: "" },
  })

  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Profile Settings</CardTitle>
        <CardDescription>Update your profile information below.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-input" onSubmit={form.handleSubmit((data) => setSubmitted(data))}>
          <FieldGroup>
            <Controller
              name="username"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Username</FieldLabel>
                  <Input
                    {...field}
                    id="form-rhf-input-username"
                    placeholder="shadcn"
                    autoComplete="username"
                  />
                  <FieldDescription>
                    This is your public display name. Must be between 3 and 10 characters. Must only
                    contain letters, numbers, and underscores.
                  </FieldDescription>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
          </FieldGroup>
        </form>
        {submitted && (
          <pre {...stylex.props(styles.output)}>{JSON.stringify(submitted, null, 2)}</pre>
        )}
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset()
              setSubmitted(null)
            }}
          >
            Reset
          </Button>
          <Button type="submit" form="form-rhf-input">
            Save
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

Checkbox

A checkbox has no value string, so wire it by hand. Set checked from field.value and pass field.onChange to onCheckedChange. For a set of checkboxes, the value is an array and each box adds or removes its id.

Notifications
Manage your notification preferences.
Responses

Get notified for requests that take time, like research or image generation.

Tasks

Get notified when tasks you've created have updates.

"use client"

import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import * as stylex from "@stylexjs/stylex"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field"
import { FieldsetLegend, Fieldset } from "@/components/ui/fieldset"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

const tasks = [
  { id: "push", label: "Push notifications" },
  { id: "email", label: "Email notifications" },
]

const formSchema = z.object({
  responses: z.boolean(),
  tasks: z.array(z.string()).min(1, "Please select at least one notification type."),
})

type FormValues = z.infer<typeof formSchema>

const styles = stylex.create({
  card: { width: "100%", maxWidth: "28rem" },
  output: {
    marginBlockStart: space.s4,
    overflowX: "auto",
    borderRadius: radius.md,
    backgroundColor: color.muted,
    padding: space.s3,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
  },
})

export default function FormRhfCheckbox() {
  const [submitted, setSubmitted] = useState<FormValues | null>(null)
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { responses: true, tasks: [] },
  })

  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Notifications</CardTitle>
        <CardDescription>Manage your notification preferences.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-checkbox" onSubmit={form.handleSubmit((data) => setSubmitted(data))}>
          <FieldGroup>
            <Controller
              name="responses"
              control={form.control}
              render={({ field }) => (
                <Fieldset>
                  <FieldsetLegend variant="label">Responses</FieldsetLegend>
                  <FieldDescription>
                    Get notified for requests that take time, like research or image generation.
                  </FieldDescription>
                  <FieldGroup gap={3}>
                    <Field orientation="horizontal" disabled>
                      <Checkbox
                        id="form-rhf-checkbox-responses"
                        name={field.name}
                        checked={field.value}
                        onCheckedChange={field.onChange}
                      />
                      <FieldLabel>Push notifications</FieldLabel>
                    </Field>
                  </FieldGroup>
                </Fieldset>
              )}
            />
            <FieldSeparator />
            <Controller
              name="tasks"
              control={form.control}
              render={({ field, fieldState }) => (
                <Fieldset>
                  <FieldsetLegend variant="label">Tasks</FieldsetLegend>
                  <FieldDescription>
                    Get notified when tasks you&apos;ve created have updates.
                  </FieldDescription>
                  <FieldGroup gap={3}>
                    {tasks.map((task) => (
                      <Field key={task.id} orientation="horizontal" invalid={fieldState.invalid}>
                        <Checkbox
                          id={`form-rhf-checkbox-${task.id}`}
                          name={field.name}
                          checked={field.value.includes(task.id)}
                          onCheckedChange={(checked) =>
                            field.onChange(
                              checked
                                ? [...field.value, task.id]
                                : field.value.filter((value) => value !== task.id),
                            )
                          }
                        />
                        <FieldLabel>{task.label}</FieldLabel>
                      </Field>
                    ))}
                  </FieldGroup>
                  <FieldError errors={[fieldState.error]} />
                </Fieldset>
              )}
            />
          </FieldGroup>
        </form>
        {submitted && (
          <pre {...stylex.props(styles.output)}>{JSON.stringify(submitted, null, 2)}</pre>
        )}
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset()
              setSubmitted(null)
            }}
          >
            Reset
          </Button>
          <Button type="submit" form="form-rhf-checkbox">
            Save
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}
form.tsx
<Controller
  name="tasks"
  control={form.control}
  render={({ field, fieldState }) => (
    <Fieldset>
      <FieldsetLegend variant="label">Tasks</FieldsetLegend>
      <FieldDescription>Get notified when tasks you've created have updates.</FieldDescription>
      <FieldGroup gap={3}>
        {tasks.map((task) => (
          <Field key={task.id} orientation="horizontal" invalid={fieldState.invalid}>
            <Checkbox
              id={`form-rhf-checkbox-${task.id}`}
              name={field.name}
              checked={field.value.includes(task.id)}
              onCheckedChange={(checked) =>
                field.onChange(
                  checked
                    ? [...field.value, task.id]
                    : field.value.filter((value) => value !== task.id),
                )
              }
            />
            <FieldLabel>{task.label}</FieldLabel>
          </Field>
        ))}
      </FieldGroup>
      <FieldError errors={[fieldState.error]} />
    </Fieldset>
  )}
/>

The error belongs to the set, not to one box, so render it after the FieldGroup inside the Fieldset.

Complex forms

This form has a choice-card set, an email field and a required agreement. The agreement's error renders inside FieldContent, under its description.

You're almost there!
Choose your add-ons and billing details.
Add-ons

Select additional features you'd like to include.

Receipts and invoices go here.

You agree to the terms of service and the privacy policy.

"use client"

import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import * as stylex from "@stylexjs/stylex"
import { Controller, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
  FieldTitle,
} from "@/components/ui/field"
import { FieldsetLegend, Fieldset } from "@/components/ui/fieldset"
import { Input } from "@/components/ui/input"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

const addons = [
  { id: "analytics", title: "Analytics", description: "Advanced analytics and reporting" },
  { id: "backup", title: "Backup", description: "Automated daily backups" },
  { id: "support", title: "Priority Support", description: "24/7 premium customer support" },
]

const formSchema = z.object({
  addons: z
    .array(z.string())
    .min(1, "Please select at least one add-on")
    .max(2, "You can select up to 2 add-ons"),
  billingEmail: z.email("Enter a valid email address."),
  terms: z.boolean().refine((value) => value, "You must accept the terms to continue."),
})

type FormValues = z.infer<typeof formSchema>

const styles = stylex.create({
  card: { width: "100%", maxWidth: "24rem" },
  output: {
    marginBlockStart: space.s4,
    overflowX: "auto",
    borderRadius: radius.md,
    backgroundColor: color.muted,
    padding: space.s3,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
  },
})

export default function FormRhfComplex() {
  const [submitted, setSubmitted] = useState<FormValues | null>(null)
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { addons: [], billingEmail: "", terms: false },
  })

  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>You&apos;re almost there!</CardTitle>
        <CardDescription>Choose your add-ons and billing details.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-complex" onSubmit={form.handleSubmit((data) => setSubmitted(data))}>
          <FieldGroup>
            <Controller
              name="addons"
              control={form.control}
              render={({ field, fieldState }) => (
                <Fieldset>
                  <FieldsetLegend variant="label">Add-ons</FieldsetLegend>
                  <FieldDescription>
                    Select additional features you&apos;d like to include.
                  </FieldDescription>
                  <FieldGroup gap={3}>
                    {addons.map((addon) => {
                      const checked = field.value.includes(addon.id)
                      return (
                        <FieldLabel
                          key={addon.id}
                          htmlFor={`form-rhf-complex-${addon.id}`}
                          checked={checked}
                        >
                          <Field orientation="horizontal" invalid={fieldState.invalid}>
                            <FieldContent>
                              <FieldTitle>{addon.title}</FieldTitle>
                              <FieldDescription>{addon.description}</FieldDescription>
                            </FieldContent>
                            <Checkbox
                              id={`form-rhf-complex-${addon.id}`}
                              name={field.name}
                              checked={checked}
                              onCheckedChange={(next) => {
                                field.onChange(
                                  next
                                    ? [...field.value, addon.id]
                                    : field.value.filter((value) => value !== addon.id),
                                )
                                field.onBlur()
                              }}
                            />
                          </Field>
                        </FieldLabel>
                      )
                    })}
                  </FieldGroup>
                  <FieldError errors={[fieldState.error]} />
                </Fieldset>
              )}
            />
            <FieldSeparator />
            <Controller
              name="billingEmail"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field invalid={fieldState.invalid}>
                  <FieldLabel>Billing Email</FieldLabel>
                  <Input
                    {...field}
                    id="form-rhf-complex-billing-email"
                    type="email"
                    placeholder="billing@example.com"
                    autoComplete="email"
                  />
                  <FieldDescription>Receipts and invoices go here.</FieldDescription>
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <FieldSeparator />
            <Controller
              name="terms"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field orientation="horizontal" invalid={fieldState.invalid}>
                  <Checkbox
                    id="form-rhf-complex-terms"
                    name={field.name}
                    checked={field.value}
                    onCheckedChange={field.onChange}
                  />
                  <FieldContent>
                    <FieldLabel>Accept the terms</FieldLabel>
                    <FieldDescription>
                      You agree to the terms of service and the privacy policy.
                    </FieldDescription>
                    <FieldError errors={[fieldState.error]} />
                  </FieldContent>
                </Field>
              )}
            />
          </FieldGroup>
        </form>
        {submitted && (
          <pre {...stylex.props(styles.output)}>{JSON.stringify(submitted, null, 2)}</pre>
        )}
      </CardContent>
      <CardFooter>
        <Field>
          <Button type="submit" form="form-rhf-complex">
            Save Preferences
          </Button>
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset()
              setSubmitted(null)
            }}
          >
            Reset
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

Resetting the form

Use form.reset() to return every field to its default value.

<Button type="button" variant="outline" onClick={() => form.reset()}>
  Reset
</Button>

Array fields

useFieldArray manages a list of fields the user can add to and remove from.

Contact Emails
Manage your contact email addresses.
Email Addresses

Add up to 5 email addresses where we can contact you.

"use client"

import { useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import * as stylex from "@stylexjs/stylex"
import { X } from "lucide-react"
import { Controller, useFieldArray, useForm } from "react-hook-form"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Field, FieldContent, FieldDescription, FieldError, FieldGroup } from "@/components/ui/field"
import { FieldsetLegend, Fieldset } from "@/components/ui/fieldset"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { color, font, leading, radius, space, text } from "@/theme/tokens.stylex"

const formSchema = z.object({
  emails: z
    .array(z.object({ address: z.email("Enter a valid email address.") }))
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
})

type FormValues = z.infer<typeof formSchema>

const styles = stylex.create({
  card: { width: "100%", maxWidth: "28rem" },
  output: {
    marginBlockStart: space.s4,
    overflowX: "auto",
    borderRadius: radius.md,
    backgroundColor: color.muted,
    padding: space.s3,
    fontFamily: font.mono,
    fontSize: text.xs,
    lineHeight: leading.xs,
  },
})

export default function FormRhfArray() {
  const [submitted, setSubmitted] = useState<FormValues | null>(null)
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: { emails: [{ address: "" }, { address: "" }] },
  })
  const { fields, append, remove } = useFieldArray({ control: form.control, name: "emails" })

  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Contact Emails</CardTitle>
        <CardDescription>Manage your contact email addresses.</CardDescription>
      </CardHeader>
      <CardContent>
        <form id="form-rhf-array" onSubmit={form.handleSubmit((data) => setSubmitted(data))}>
          <Fieldset>
            <FieldsetLegend variant="label">Email Addresses</FieldsetLegend>
            <FieldDescription>
              Add up to 5 email addresses where we can contact you.
            </FieldDescription>
            <FieldGroup gap={4}>
              {fields.map((item, index) => (
                <Controller
                  key={item.id}
                  name={`emails.${index}.address`}
                  control={form.control}
                  render={({ field, fieldState }) => (
                    <Field orientation="horizontal" invalid={fieldState.invalid}>
                      <FieldContent>
                        <InputGroup>
                          <Input
                            {...field}
                            id={`form-rhf-array-email-${index}`}
                            aria-label={`Email ${index + 1}`}
                            placeholder="name@example.com"
                            type="email"
                            autoComplete="email"
                          />
                          {fields.length > 1 && (
                            <InputGroupAddon align="inline-end">
                              <InputGroupButton
                                type="button"
                                variant="ghost"
                                size="icon-xs"
                                onClick={() => remove(index)}
                                aria-label={`Remove email ${index + 1}`}
                              >
                                <X />
                              </InputGroupButton>
                            </InputGroupAddon>
                          )}
                        </InputGroup>
                        <FieldError errors={[fieldState.error]} />
                      </FieldContent>
                    </Field>
                  )}
                />
              ))}
              <Field orientation="horizontal">
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  onClick={() => append({ address: "" })}
                  disabled={fields.length >= 5}
                >
                  Add Email Address
                </Button>
              </Field>
            </FieldGroup>
          </Fieldset>
        </form>
        {submitted && (
          <pre {...stylex.props(styles.output)}>{JSON.stringify(submitted, null, 2)}</pre>
        )}
      </CardContent>
      <CardFooter>
        <Field orientation="horizontal">
          <Button
            type="button"
            variant="outline"
            onClick={() => {
              form.reset()
              setSubmitted(null)
            }}
          >
            Reset
          </Button>
          <Button type="submit" form="form-rhf-array">
            Save
          </Button>
        </Field>
      </CardFooter>
    </Card>
  )
}

Using useFieldArray

It returns the current fields plus append and remove.

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 field structure

Wrap the list in a Fieldset with a FieldsetLegend and FieldDescription.

form.tsx
<Fieldset>
  <FieldsetLegend variant="label">Email Addresses</FieldsetLegend>
  <FieldDescription>Add up to 5 email addresses where we can contact you.</FieldDescription>
  <FieldGroup gap={4}>{/* Array items go here */}</FieldGroup>
</Fieldset>

Controller pattern for array items

Map over fields with one Controller per item. Key each one by field.id, not by index, or a removed row keeps the next row's state.

form.tsx
{
  fields.map((item, index) => (
    <Controller
      key={item.id}
      name={`emails.${index}.address`}
      control={form.control}
      render={({ field, fieldState }) => (
        <Field orientation="horizontal" invalid={fieldState.invalid}>
          <FieldContent>
            <InputGroup invalid={fieldState.invalid}>
              <Input
                {...field}
                id={`form-rhf-array-email-${index}`}
                aria-label={`Email ${index + 1}`}
                placeholder="name@example.com"
                type="email"
                autoComplete="email"
              />
              {/* Remove button */}
            </InputGroup>
            <FieldError errors={[fieldState.error]} />
          </FieldContent>
        </Field>
      )}
    />
  ))
}

Adding items

Call append with a new item's default value.

form.tsx
<Button
  type="button"
  variant="outline"
  size="sm"
  onClick={() => append({ address: "" })}
  disabled={fields.length >= 5}
>
  Add Email Address
</Button>

Removing items

Call remove with the index. Render the button only while more than one row remains, so the list cannot be emptied from the UI.

form.tsx
{
  fields.length > 1 && (
    <InputGroupAddon align="inline-end">
      <InputGroupButton
        type="button"
        variant="ghost"
        size="icon-xs"
        onClick={() => remove(index)}
        aria-label={`Remove email ${index + 1}`}
      >
        <X />
      </InputGroupButton>
    </InputGroupAddon>
  )
}

Array validation

Validate the list with z.array. A length rule on the array reports against the array itself, so render its error after the FieldGroup.

form.tsx
const formSchema = z.object({
  emails: z
    .array(z.object({ address: z.email("Enter a valid email address.") }))
    .min(1, "Add at least one email address.")
    .max(5, "You can add up to 5 email addresses."),
})

Base UI Form

Use Form for native validation and server errors without React Hook Form. Pass server messages through errors, keyed by Field name. Editing a field clears its server error.

"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Form } from "@/components/ui/form"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

const styles = stylex.create({ form: { width: "20rem" } })

export default function FormDemo() {
  const [errors, setErrors] = useState<Record<string, string>>({})
  const [submitted, setSubmitted] = useState(false)
  return (
    <Form
      errors={errors}
      xstyle={styles.form}
      onFormSubmit={(values) => {
        if (values.username === "admin") {
          setErrors({ username: "This username is taken." })
          setSubmitted(false)
        } else {
          setErrors({})
          setSubmitted(true)
        }
      }}
    >
      <FieldGroup>
        <Field name="username">
          <FieldLabel>Username</FieldLabel>
          <Input required placeholder="Try admin" />
          <FieldError />
        </Field>
        <Button type="submit">Save</Button>
        {submitted && <output>Profile saved.</output>}
      </FieldGroup>
    </Form>
  )
}