Forms with React Hook Form and Zod
Build validated forms with @azmr/ui/rhf and @azmr/ui/zod, with register() and no Controller, schema validation, async checks and an accessible error summary.
View as Markdown for coding assistants, or see llms.txt.
Install
The adapters use React Hook Form 7 and Zod 4, which are optional peer dependencies of @azmr/ui:
pnpm add react-hook-form zodimport { Form, FormErrorSummary, FormField } from "@azmr/ui/rhf";
import { fieldsFromSchema, zodResolver } from "@azmr/ui/zod";A complete form
<Form form={form} onSubmit={setCreated} className="flex w-full max-w-lg flex-col gap-6">
<FormErrorSummary />
<FormSection title="Your business">
<FormField name="business" label="Business name" required>
<Input autoComplete="organization" />
</FormField>
<FormField
name="slug"
label="Web address"
description="Try northline to see the async check."
required
>
<Input autoCapitalize="none" spellCheck={false} />
</FormField>
<FormField
name="budget"
label="Monthly budget"
required
rules={{ setValueAs: numberOrNull }}
>
<MoneyField min={0} />
</FormField>
</FormSection>
<FormSection title="Your account">
<FormField name="email" label="Email" required>
<Input type="email" autoComplete="email" />
</FormField>
<FormField name="password" label="Password" required>
<PasswordInput
autoComplete="new-password"
requirements={[passwordRequirements.minLength(12)]}
/>
</FormField>
</FormSection>
<div className="flex items-center gap-3">
<Button type="submit" loading={form.formState.isSubmitting} loadingText="Checking…">
Create account
</Button>
<p role="status" className="text-sm text-muted-foreground">
{created ? `Created ${created.business} at ${created.slug}.azmara.io.` : ""}
</p>
</div>
</Form>Submit it empty to see the summary take focus and list each error; each link moves to its field. Then try northline as the web address: the schema checks it asynchronously, as a real server lookup would.
const schema = z.object({
business: z.string().trim().min(2, "Enter your business name"),
slug: z.string().regex(/^[a-z0-9-]{3,30}$/, "Use 3 to 30 letters, numbers or hyphens")
.refine(isFree, "That web address is taken; try another"),
email: z.email("Enter an email address like aroha@example.co.nz"),
budget: z.number("Enter a monthly budget").min(0),
});
export function Signup() {
const form = useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>({
resolver: zodResolver(schema),
shouldFocusError: false,
});
return (
<Form form={form} onSubmit={createAccount}>
<FormErrorSummary />
<FormField name="business" label="Business name" required>
<Input autoComplete="organization" />
</FormField>
<FormField name="slug" label="Web address" required>
<Input />
</FormField>
<FormField name="email" label="Email" required>
<Input type="email" autoComplete="email" />
</FormField>
<FormField name="budget" label="Monthly budget" required rules={{ setValueAs: numberOrNull }}>
<MoneyField min={0} />
</FormField>
<Button type="submit">Create account</Button>
</Form>
);
}Form
Form renders <form noValidate>, provides the form to everything inside it, and submits through handleSubmit. Pass what useForm() returned as form, the success handler as onSubmit, and optionally onInvalid. The browser's own validation bubbles are off, so the form's messages are the only ones people see.
FormField
FormField is a Field bound to the form by name:
- It calls
register(name, rules)on its one control, so the control's realrefis used and there is noController. This works forInput,Textarea,PasswordInput,NumberFieldandMoneyField. A control's ownonChangeandonBlurstill run. - It shows the field's error, marks the control invalid, and reads
requiredfrom the prop or from arequiredrule. rulestakes React Hook Form's register options. With a schema resolver you only needsetValueAs, such asnumberOrNullfor a number field.- Dirty and touched state come from the real input, so
formState.dirtyFieldsandtouchedFieldswork as usual.
For a control without an input ref, such as a toggle, a select or a date picker, set controlled: the field uses useController and passes value, onValueChange and onBlur to the child. Or render the control yourself:
<FormField
name="terms"
label="Terms"
rules={{ validate: (value) => value || "Accept the terms to continue" }}
render={({ value, onChange, invalid }) => (
<input
type="checkbox"
checked={value}
aria-invalid={invalid || undefined}
onChange={(event) => onChange(event.target.checked)}
/>
)}
/>render also gets control: the id and ARIA props that tie the label, description and error to the control. Spread it on the element that takes focus. That is how a control from another library, such as a Radix (shadcn) Select, gets its label and its error read out:
<FormField
name="customer_id"
label="Customer"
render={({ value, onChange, onBlur, ref, control }) => (
<Select name="customer_id" value={value || undefined} onValueChange={onChange}>
<SelectTrigger ref={ref} onBlur={onBlur} {...control}>
<SelectValue placeholder="Select a customer…" />
</SelectTrigger>
<SelectContent>{/* items */}</SelectContent>
</Select>
)}
/>FormErrorSummary
The form's ErrorSummary. It reads the errors and the submit count from the form, takes focus after every failed submit and links each error to its field, listed in the order the fields appear on the page, even when an async check finishes last. Set shouldFocusError: false in useForm so focus lands on the summary, not on the first field.
FormFieldArray
Repeating rows of fields, such as invoice lines, run through useFieldArray: add, remove and reorder by drag or keyboard, with focus and announcements handled. Put a FormField in each row with row.name(). See Field array for the props and an invoice demo with GST.
zodResolver
zodResolver(schema) validates the form with a Zod 4 schema; you don't need @hookform/resolvers.
- It runs
safeParseAsync, so async refinements work. - On success,
onSubmitgets the parsed output: trimmed strings, defaults and transforms applied. Type the form withuseForm<z.input<typeof schema>, unknown, z.output<typeof schema>>. - Each issue lands on its field path, nested objects and arrays included (
address.city,lines.1.amount). An issue on the whole object, from an object-levelrefine, goes toerrors.root, which the summary shows without a link. - With
criteriaMode: "all", every message per field is kept intypes.
fieldsFromSchema
fieldsFromSchema(schema) describes each field of a z.object: its name, a label, the kind of control it needs, whether it is required, and its limits. It is the start of the generated forms that come later.
fieldsFromSchema(
z.object({
fullName: z.string().min(2).describe("Full name"),
creditLimit: z.number().min(0).meta({ fieldType: "money" }),
tier: z.enum(["bronze", "silver", "gold"]),
notes: z.string().optional(),
}),
);
// [
// { name: "fullName", label: "Full name", type: "text", required: true, min: 2 },
// { name: "creditLimit", label: "Credit limit", type: "money", required: true, min: 0 },
// { name: "tier", label: "Tier", type: "enum", required: true, options: ["bronze", "silver", "gold"] },
// { name: "notes", label: "Notes", type: "text", required: false },
// ]Labels come from .describe(), .meta({ label }), or the key in sentence case. Types are text, email, url, number, money (from .meta({ fieldType: "money" })), date, boolean, enum or custom. Optional, nullable and defaulted fields are not required. Other .meta() keys are passed through as meta.
@azmr/ui/zod has no React in it, so a schema module that uses it can be shared with server code.
Accessibility
- Every field is labelled, described and wired by
Field; errors are announced politely and linked witharia-errormessage. - After a failed submit the summary takes focus and lists every problem as a link, in page order (WCAG 3.3.1 and 3.3.3).
- Keep each rule's message specific and actionable: say what to enter, not only what is wrong.