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.

Note: Browser-native validation is disabled with noValidate on the <form> element so that custom Zod errors always show.

Bug Report

Help us improve by reporting bugs you encounter.

0/300

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

Installation

Install React Hook Form, Zod, and the official resolver:

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

Approach

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 form
  • register — connects native inputs to the form state
  • <Controller /> — connects Radix UI components to the form state
  • useFieldArray — 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.

form.tsx
<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 />:

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

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

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

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

form.tsx
const form = useForm({
  resolver: zodResolver(formSchema),
  mode: "onBlur", // validate when the user leaves a field
})
ModeDescription
"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.

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

This is your public display name. Must be 3–20 characters. Letters, numbers, and underscores only.

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

0/300

Tell us more about yourself. This will help us personalise your experience.

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

For best results, select the language you speak.

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

Email notifications

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

You can upgrade or downgrade at any time.

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

Require a second factor when signing in.

Get notified when a new session starts from an unrecognised device.

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

form.tsx
// 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.

Email Addresses

Add up to 5 addresses where we can reach you.

Set up useFieldArray

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

form.tsx
{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

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

Array validation schema

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