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.
"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 zodnpm install react-hook-form @hookform/resolvers zodyarn add react-hook-form @hookform/resolvers zodbun add react-hook-form @hookform/resolvers zodThe examples also use Field, Input, Checkbox, Button and Card.
Approach
useFormholds the form state.Controllerconnects one control to it and reports that control's state.Fieldlays out the label, control, description and error.zodResolverruns 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.
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.
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.
"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.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { title: "", description: "" },
})Validation modes
Use mode to choose when validation runs.
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
Render FieldError with errors={[fieldState.error]} next to the control.
Set invalid on Field. The control inherits it.
<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.
"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.
"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'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>
)
}<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.
"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'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'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.
"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.
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.
<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.
{
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.
<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.
{
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.
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>
)
}