Skip to the content

Guides

Forms

Build forms from the Field parts and the controls: labels, descriptions, errors, required fields, focus and server errors, with recipes for React Hook Form, TanStack Form and plain React.

A field

Field holds one control with its label, its description and its error. It lays them out and colours them; it does not connect them, so the ids are yours:

import { Field, FieldDescription, FieldError, FieldLabel } from "@booleanpress/ui/field"
import { Input } from "@booleanpress/ui/input"

export function MailerName({ error }: { error?: string }) {
  return (
    <Field data-invalid={Boolean(error)}>
      <FieldLabel htmlFor="mailer-name">Name</FieldLabel>
      <Input
        id="mailer-name"
        name="name"
        aria-invalid={Boolean(error)}
        aria-describedby={error ? "mailer-name-help mailer-name-error" : "mailer-name-help"}
      />
      <FieldDescription id="mailer-name-help">Shown in the list of mailers.</FieldDescription>
      {error && <FieldError id="mailer-name-error">{error}</FieldError>}
    </Field>
  )
}
  • Label. FieldLabel's htmlFor is the control's id. Every control takes it: on a Select it goes on SelectTrigger, on a DatePicker or an InputNumber on the component itself, which hands it to its text field. A click on the label moves focus to the control, or flips a checkbox or switch.
  • Description. FieldDescription says what to enter before anyone gets it wrong: a format, a limit, where the value is used. Tie it to the control with aria-describedby, so it is read with the name.
  • Groups. FieldGroup spaces fields; FieldSet with a FieldLegend names a set, such as a radio group or the parts of an address. A checkbox or switch sits beside its label with orientation="horizontal" and FieldContent.

Use the controls' own size and variant props, or the provider's controlSize and fieldVariant, for a compact or filled form: the label and the messages keep their size.

Required fields

Say which fields can be left empty, not only which cannot. When most fields are required, add "(optional)" to the label of the others; when most are optional, mark the required ones. A mark must be text that screen readers can read, or the attribute that says it:

  • Native fields (Input, Textarea, NativeSelect, InputNumber, DatePicker) take required, which screen readers announce. Put noValidate on the form so the browser's own bubbles do not replace your messages.
  • The other controls take aria-required="true": on SelectTrigger, Checkbox, RadioGroup, ComboboxInput.
  • A visual asterisk is aria-hidden="true" when required already says it, and needs a line above the form that explains it.

Do not disable the submit button until the form is valid: a disabled button gives no reason, and people cannot find out what is missing. Let them submit, and show the errors.

Errors

Show errors when the form is submitted, and then as each field is corrected. Errors that appear while someone is still typing their first characters only interrupt.

For each field in error:

  1. aria-invalid="true" on the control. Every control draws its --invalid edge from it, and screen readers announce "invalid".
  2. data-invalid="true" on the Field, which colours its label.
  3. A FieldError with the message, tied to the control with aria-describedby. It is a role="alert" region, so a message that appears is announced. It takes the text as children, or errors, a list of { message } objects as React Hook Form, TanStack Form and Zod give them: it shows one as text and several as a list, without repeats.

Write the message as what to do, in the field's own words: "Enter a full email address, such as hello@example.com", not "Invalid input". Never colour alone: the message and aria-invalid carry the error.

Focus on the first error

After a submit that fails, move focus to the first field in error. Its name, its value and its message are read together, and keyboard users start where the work is. Do not move focus while someone types.

React Hook Form does this itself (shouldFocusError, on by default) when each control receives field.ref; the recipes below show where the ref goes on each control. Elsewhere, focus the first control the browser draws as invalid, once the errors are on the page:

export function focusFirstInvalid(form: HTMLFormElement | null) {
  form?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()
}

Every control puts aria-invalid on its focusable element (the field of a DatePicker or an InputNumber, the trigger of a Select, the box of a Checkbox), so the query finds the right one.

Server errors

Check on the server too: the browser's checks are a convenience, not a guarantee. A server's answer is one of two kinds:

  • About one field ("This address is not verified for sending"): put it on that field, exactly as a check in the browser would, and focus the field. In React Hook Form, form.setError("fromEmail", { type: "server", message }, { shouldFocus: true }); in TanStack Form, return { fields: { fromEmail: { message } } } from an onSubmitAsync validator; in plain React, return it in the action's state.
  • About the whole form ("The mailer could not be saved. Try again."): show it in an Alert with variant="destructive" above the submit button. It is a role="alert" region, so it is read when it appears.

Keep what people typed when a submit fails, and keep the submit button enabled so they can try again. While the request runs, Button's loading shows a spinner in it.

Recipes

The same form three ways: a mailer's name, sender address, provider, daily limit, open tracking and the terms. The recipes use React Hook Form 7 with Zod 4 (react-hook-form, @hookform/resolvers, zod), TanStack Form 1 (@tanstack/react-form, zod), or React 19 alone. None of these packages comes with @booleanpress/ui: install the ones your recipe uses.

Each control is wired through Controller, which hands it field (the value, the change and blur handlers, the ref) and fieldState (the error). zodResolver runs the schema on submit, and again on every change after the first submit. Focus moves to the first field in error on its own, because every control receives field.ref.

import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Alert, AlertDescription } from "@booleanpress/ui/alert"
import { Button } from "@booleanpress/ui/button"
import { Checkbox } from "@booleanpress/ui/checkbox"
import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@booleanpress/ui/field"
import { Input } from "@booleanpress/ui/input"
import { InputNumber } from "@booleanpress/ui/input-number"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@booleanpress/ui/select"
import { Switch } from "@booleanpress/ui/switch"

const mailerSchema = z.object({
  name: z.string().trim().min(1, "Enter a name for the mailer."),
  fromEmail: z.email("Enter a full email address, such as hello@example.com."),
  provider: z.string().min(1, "Choose a provider."),
  dailyLimit: z.number({ error: "Enter a daily limit." }).int().min(1, "Send at least one email a day."),
  trackOpens: z.boolean(),
  terms: z.boolean().refine((accepted) => accepted, "Accept the terms to save the mailer."),
})

type MailerValues = z.infer<typeof mailerSchema>

interface SaveError {
  message?: string
  fields?: Partial<Record<keyof MailerValues, string>>
}

export function MailerForm() {
  const form = useForm<MailerValues>({
    resolver: zodResolver(mailerSchema),
    defaultValues: { name: "", fromEmail: "", provider: "", dailyLimit: 500, trackOpens: true, terms: false },
  })

  async function onSubmit(values: MailerValues) {
    const response = await fetch("/api/mailers", { method: "POST", body: JSON.stringify(values) })
    if (response.ok) return
    const body: SaveError = await response.json()
    const fields = Object.entries(body.fields ?? {}) as [keyof MailerValues, string][]
    fields.forEach(([name, message], index) => form.setError(name, { type: "server", message }, { shouldFocus: index === 0 }))
    if (fields.length === 0) {
      form.setError("root.server", { message: body.message ?? "The mailer could not be saved. Try again." })
    }
  }

  const serverError = form.formState.errors.root?.server?.message

  return (
    <form noValidate onSubmit={form.handleSubmit(onSubmit)} className="w-full max-w-md">
      <FieldGroup>
        <Controller
          name="name"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor="mailer-name">Name</FieldLabel>
              <Input
                {...field}
                id="mailer-name"
                required
                aria-invalid={fieldState.invalid}
                aria-describedby={fieldState.invalid ? "mailer-name-error" : undefined}
              />
              {fieldState.invalid && <FieldError id="mailer-name-error" errors={[fieldState.error]} />}
            </Field>
          )}
        />
        <Controller
          name="fromEmail"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor="mailer-from">Send from</FieldLabel>
              <Input
                {...field}
                id="mailer-from"
                type="email"
                autoComplete="email"
                required
                aria-invalid={fieldState.invalid}
                aria-describedby={fieldState.invalid ? "mailer-from-help mailer-from-error" : "mailer-from-help"}
              />
              <FieldDescription id="mailer-from-help">The address your recipients see.</FieldDescription>
              {fieldState.invalid && <FieldError id="mailer-from-error" errors={[fieldState.error]} />}
            </Field>
          )}
        />
        <Controller
          name="provider"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor="mailer-provider">Provider</FieldLabel>
              <Select name={field.name} value={field.value} onValueChange={field.onChange}>
                <SelectTrigger
                  ref={field.ref}
                  id="mailer-provider"
                  onBlur={field.onBlur}
                  aria-required="true"
                  aria-invalid={fieldState.invalid}
                  aria-describedby={fieldState.invalid ? "mailer-provider-error" : undefined}
                >
                  <SelectValue placeholder="Choose a provider" />
                </SelectTrigger>
                <SelectContent>
                  <SelectItem value="ses">Amazon SES</SelectItem>
                  <SelectItem value="postmark">Postmark</SelectItem>
                  <SelectItem value="smtp">Other SMTP server</SelectItem>
                </SelectContent>
              </Select>
              {fieldState.invalid && <FieldError id="mailer-provider-error" errors={[fieldState.error]} />}
            </Field>
          )}
        />
        <Controller
          name="dailyLimit"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field data-invalid={fieldState.invalid}>
              <FieldLabel htmlFor="mailer-limit">Daily limit</FieldLabel>
              <InputNumber
                id="mailer-limit"
                name={field.name}
                value={field.value}
                onValueChange={field.onChange}
                onBlur={field.onBlur}
                inputRef={field.ref}
                min={1}
                required
                aria-invalid={fieldState.invalid}
                aria-describedby={fieldState.invalid ? "mailer-limit-error" : undefined}
              />
              {fieldState.invalid && <FieldError id="mailer-limit-error" errors={[fieldState.error]} />}
            </Field>
          )}
        />
        <Controller
          name="trackOpens"
          control={form.control}
          render={({ field }) => (
            <Field orientation="horizontal">
              <Switch
                ref={field.ref}
                id="mailer-track"
                name={field.name}
                checked={field.value}
                onCheckedChange={field.onChange}
                aria-describedby="mailer-track-help"
              />
              <FieldContent>
                <FieldLabel htmlFor="mailer-track">Track opens</FieldLabel>
                <FieldDescription id="mailer-track-help">Adds an invisible image to each email.</FieldDescription>
              </FieldContent>
            </Field>
          )}
        />
        <Controller
          name="terms"
          control={form.control}
          render={({ field, fieldState }) => (
            <Field orientation="horizontal" data-invalid={fieldState.invalid}>
              <Checkbox
                ref={field.ref}
                id="mailer-terms"
                name={field.name}
                checked={field.value}
                onCheckedChange={(checked) => field.onChange(checked === true)}
                aria-required="true"
                aria-invalid={fieldState.invalid}
                aria-describedby={fieldState.invalid ? "mailer-terms-error" : undefined}
              />
              <FieldContent>
                <FieldLabel htmlFor="mailer-terms">I accept the sending terms</FieldLabel>
                {fieldState.invalid && <FieldError id="mailer-terms-error" errors={[fieldState.error]} />}
              </FieldContent>
            </Field>
          )}
        />
        {serverError && (
          <Alert variant="destructive">
            <AlertDescription>{serverError}</AlertDescription>
          </Alert>
        )}
        <Button type="submit" loading={form.formState.isSubmitting}>
          Save mailer
        </Button>
      </FieldGroup>
    </form>
  )
}

Every field has a default in defaultValues, so each control is controlled from the first render. A control that starts empty with null (a DatePicker, a Combobox) can be left out of defaultValues: pass value={field.value ?? null}, and the schema's z.date({ error: "Choose a start date." }) turns the empty value into your message.

Each control's value

What each control holds, how to wire it to a form library, and what its name sends with a plain form submit.

Control Value Wire Empty With name, a form submit sends
Input, Textarea string value and onChange (React Hook Form: {...field}) "" the text
Select an item's value, a string value and onValueChange on Select; the ref and onBlur on SelectTrigger "" (shows the placeholder) the value, from a hidden native select inside the form
Checkbox true, false or "indeterminate" checked, and onCheckedChange={(checked) => onChange(checked === true)} for a plain boolean false its value ("on") when checked; nothing when not
Switch boolean checked and onCheckedChange false "on" when on; nothing when off
Combobox an item's value (a string or an object); an array with multiple value and onValueChange on Combobox null ([] with multiple) the value as text; an object through itemToStringValue, or its value key
DatePicker a Date; a Date[] with mode="multiple" value and onValueChange; the ref reaches the text field null ([] with multiple) ISO 8601: 2026-10-14 for a day, the full instant with showTime; several dates joined by commas
InputNumber a number value and onValueChange; the ref as inputRef null the number
FileUpload the files in onFilesChange, each { id, file, status, progress } onFilesChange={(files) => onChange(files.map((entry) => entry.file))} [] nothing: append the files to your FormData yourself, or let onUpload send them

In Zod 4 these are: z.string().min(1, "โ€ฆ") for a required text or Select; z.boolean(), with .refine((value) => value, "โ€ฆ") for a box that must be ticked; z.string({ error: "โ€ฆ" }) for a Combobox of strings; z.date({ error: "โ€ฆ" }) for a DatePicker; z.number({ error: "โ€ฆ" }) for an InputNumber; z.array(z.instanceof(File)).min(1, "โ€ฆ") for a FileUpload. The error message is the one shown for an empty value, as null is not a string, a date or a number.

A typed date that a DatePicker cannot read stays in its field, marks the field invalid and empties the value, so a required-date check fails with your message while the text waits to be corrected. An InputNumber brings a number outside min and max back into range when it loses focus.