Forms
React Hook Form
Build forms in React using React Hook Form and Zod.
In this guide we cover building forms with the Aether UI primitives (Input, Select, Checkbox, Switch, and more), schema validation with Zod, error display, validation modes, and dynamic array fields.
Demo
We are going to build the following form. It has a text input and a textarea with a character counter. On submit, the form data is validated against a Zod schema and errors are displayed next to each field.
noValidate on the <form> element so that custom Zod errors always show.Bug Report
Help us improve by reporting bugs you encounter.
Installation
Install React Hook Form, Zod, and the official resolver:
pnpm add react-hook-form zod @hookform/resolversApproach
This guide uses React Hook Form's useForm hook for form state and <Controller /> for Radix UI components that don't expose a native DOM input (Select, Switch, Checkbox, RadioGroup). Simple inputs and textareas use the register API.
useForm— manages form state (values, errors, submission)zodResolver— wires Zod schema validation into the formregister— connects native inputs to the form state<Controller />— connects Radix UI components to the form stateuseFieldArray— manages dynamic array fields
Anatomy
The pattern for every field is: wrap the control in a <div>, add a <Label>, render the input, then conditionally render an error message beneath it.
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>For Radix UI components, swap register for <Controller />:
<Controller
name="language"
control={control}
render={({ field, fieldState }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid}>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
</SelectContent>
</Select>
)}
/>Step 1 — Create a form schema
Define the shape and validation rules for your form using Zod. The inferred type becomes your form's values type.
import { z } from "zod"
const formSchema = z.object({
title: z
.string()
.min(5, "Bug title must be at least 5 characters.")
.max(60, "Bug title must be at most 60 characters."),
description: z
.string()
.min(20, "Description must be at least 20 characters.")
.max(300, "Description must be at most 300 characters."),
})
type FormValues = z.infer<typeof formSchema>Step 2 — Set up useForm
Pass your schema to zodResolver and set optional default values.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
export function BugReportForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { title: "", description: "" },
})
function onSubmit(data: FormValues) {
// data is fully typed and validated
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
{/* fields go here */}
</form>
)
}Step 3 — Build the form
Spread register("fieldName") onto each native input, add aria-invalid when there's an error, and render the error message beneath.
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button, Input, Label, Textarea } from "@aetherstack/ui"
const formSchema = z.object({
title: z.string().min(5, "At least 5 characters.").max(60),
description: z.string().min(20, "At least 20 characters.").max(300),
})
export function BugReportForm() {
const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting } } =
useForm({ resolver: zodResolver(formSchema), defaultValues: { title: "", description: "" } })
const desc = watch("description", "")
return (
<form id="bug-form" onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="title">Bug Title</Label>
<Input id="title" aria-invalid={!!errors.title} {...register("title")} />
{errors.title && <p className="text-xs text-destructive">{errors.title.message}</p>}
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="desc">Description</Label>
<span className="text-xs text-muted-foreground">{desc.length}/300</span>
</div>
<Textarea id="desc" rows={4} aria-invalid={!!errors.description} {...register("description")} />
{errors.description && <p className="text-xs text-destructive">{errors.description.message}</p>}
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => reset()}>Reset</Button>
<Button type="submit" form="bug-form" disabled={isSubmitting}>Submit</Button>
</div>
</form>
)
}Validation
Validation modes
Pass the mode option to useForm to control when validation runs.
const form = useForm({
resolver: zodResolver(formSchema),
mode: "onBlur", // validate when the user leaves a field
})| Mode | Description |
|---|---|
| "onSubmit" | Validates only on submit (default). Best UX — no red errors while typing. |
| "onBlur" | Validates when the user leaves a field. |
| "onChange" | Validates on every keystroke. Useful for real-time feedback. |
| "onTouched" | Validates on first blur, then on every change. |
| "all" | Validates on both blur and change. |
Displaying errors
Read error messages from formState.errors. Always set aria-invalid on the control so assistive technology announces the invalid state.
const { formState: { errors } } = useForm(...)
// In JSX:
<Input
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p className="mt-1 text-xs text-destructive">{errors.email.message}</p>
)}Input
Spread register("name") directly onto <Input />. Add aria-invalid when the field has an error.
Profile Settings
Update your profile information below.
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
aria-invalid={!!errors.username}
{...register("username")}
/>
{errors.username && (
<p className="text-xs text-destructive">{errors.username.message}</p>
)}
</div>Textarea
Same pattern as <Input /> — spread register and add aria-invalid. Use watch to drive a live character counter without triggering re-validation.
Personalization
Customize your experience by telling us more about yourself.
const about = watch("about", "")
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="about">Bio</Label>
<span className="text-xs text-muted-foreground">{about.length}/300</span>
</div>
<Textarea
id="about"
rows={4}
aria-invalid={!!errors.about}
{...register("about")}
/>
</div>Select
Radix UI's <Select /> manages its own state, so it needs <Controller />. Use field.value and field.onChange, and set aria-invalid on the <SelectTrigger />.
Language Preferences
Select your preferred spoken language.
<Controller
name="language"
control={control}
render={({ field, fieldState }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger aria-invalid={fieldState.invalid}>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Spanish</SelectItem>
</SelectContent>
</Select>
)}
/>Checkbox
For multi-select checkboxes, use <Controller /> and manage an array of selected IDs in field.value.
Notifications
Manage your notification preferences.
<Controller
name="notifications"
control={control}
render={({ field, fieldState }) => (
<>
{ITEMS.map((item) => (
<div key={item.id} className="flex items-center gap-2">
<Checkbox
id={item.id}
aria-invalid={fieldState.invalid}
checked={field.value.includes(item.id)}
onCheckedChange={(checked) => {
const next = checked
? [...field.value, item.id]
: field.value.filter((v) => v !== item.id)
field.onChange(next)
}}
/>
<Label htmlFor={item.id}>{item.label}</Label>
</div>
))}
</>
)}
/>Radio Group
Use field.value and field.onValueChange on <RadioGroup />. Set aria-invalid on each <RadioGroupItem />.
Subscription Plan
See pricing and features for each plan.
<Controller
name="plan"
control={control}
render={({ field, fieldState }) => (
<RadioGroup value={field.value} onValueChange={field.onChange}>
{PLANS.map((plan) => (
<label key={plan.id} htmlFor={plan.id} className="flex items-start gap-3 ...">
<RadioGroupItem
value={plan.id}
id={plan.id}
aria-invalid={fieldState.invalid}
/>
<div>
<p>{plan.title}</p>
<p>{plan.description}</p>
</div>
</label>
))}
</RadioGroup>
)}
/>Switch
Use field.value and field.onChange on <Switch /> via <Controller />. Wrap the label and switch in a flex row so the label is on the left and the toggle is on the right.
Security Settings
Manage your account security preferences.
<Controller
name="twoFactor"
control={control}
render={({ field }) => (
<div className="flex items-center justify-between gap-4">
<Label htmlFor="two-factor">Multi-factor authentication</Label>
<Switch
id="two-factor"
checked={field.value}
onCheckedChange={field.onChange}
/>
</div>
)}
/>Resetting the form
Call form.reset() to clear all values back to their defaults and clear all validation errors. Optionally pass new default values.
// Reset to original defaults
<Button type="button" variant="outline" onClick={() => reset()}>
Reset
</Button>
// Reset to new values (e.g. after a successful save)
reset({ username: "new-default" })Array fields
Use useFieldArray to manage a dynamic list of fields — for example a list of email addresses. It provides fields, append, and remove.
Contact Emails
Manage your contact email addresses.
Set up useFieldArray
import { useFieldArray } from "react-hook-form"
const { fields, append, remove } = useFieldArray({
control,
name: "emails",
})Render array items
Map over fields and use <Controller /> for each item. Always use field.id as the React key — not the array index — to prevent stale state bugs when items are removed.
{fields.map((field, index) => (
<Controller
key={field.id} // ← use field.id, not index
name={`emails.${index}.address`}
control={control}
render={({ field: f, fieldState }) => (
<div className="flex gap-2">
<Input {...f} type="email" aria-invalid={fieldState.invalid} />
{fields.length > 1 && (
<Button type="button" variant="outline" size="icon" onClick={() => remove(index)}>
<XIcon />
</Button>
)}
</div>
)}
/>
))}Adding items
<Button
type="button"
variant="outline"
onClick={() => append({ address: "" })}
disabled={fields.length >= 5}
>
Add Email Address
</Button>Array validation schema
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."),
})Continue reading