# 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.

Source: https://platform.azmara.io/docs/guide/forms

## Install

The adapters use React Hook Form 7 and Zod 4, which are optional peer dependencies of `@azmr/ui`:

```bash
pnpm add react-hook-form zod
```

```tsx
import { Form, FormErrorSummary, FormField } from "@azmr/ui/rhf";
import { fieldsFromSchema, zodResolver } from "@azmr/ui/zod";
```

## A complete form

```tsx
"use client";

import { Button } from "@azmr/ui/button";
import { FormSection } from "@azmr/ui/form";
import { Input } from "@azmr/ui/input";
import { MoneyField, numberOrNull } from "@azmr/ui/number-field";
import { PasswordInput, passwordRequirements } from "@azmr/ui/password-input";
import { Form, FormErrorSummary, FormField } from "@azmr/ui/rhf";
import { zodResolver } from "@azmr/ui/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

// Pretend server check: these web addresses are already in use.
const TAKEN = new Set(["northline", "kiwi-plumbing"]);
const isFree = async (slug: string) => {
  await new Promise((resolve) => setTimeout(resolve, 400));
  return !TAKEN.has(slug);
};

const schema = z.object({
  business: z.string().trim().min(2, "Enter your business name"),
  slug: z
    .string()
    .trim()
    .toLowerCase()
    .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"),
  password: z.string().min(12, "Use at least 12 characters"),
  budget: z
    .number("Enter a monthly budget")
    .min(0, "Enter an amount of $0 or more")
    .max(100_000, "Enter an amount up to $100,000"),
});

type SignupInput = z.input<typeof schema>;
type SignupOutput = z.output<typeof schema>;

export default function FormsSignup() {
  const [created, setCreated] = useState<SignupOutput>();
  const form = useForm<SignupInput, unknown, SignupOutput>({
    resolver: zodResolver(schema),
    defaultValues: { business: "", slug: "", email: "", password: "" },
    shouldFocusError: false,
  });

  return (
    <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.

```tsx
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](https://platform.azmara.io/docs/components/field.md) bound to the form by `name`:

- It calls `register(name, rules)` on its one control, so the control's real `ref` is used and there is no `Controller`. This works for `Input`, `Textarea`, `PasswordInput`, `NumberField` and `MoneyField`. A control's own `onChange` and `onBlur` still run.
- It shows the field's error, marks the control invalid, and reads `required` from the prop or from a `required` rule.
- `rules` takes React Hook Form's register options. With a schema resolver you only need `setValueAs`, such as `numberOrNull` for a number field.
- Dirty and touched state come from the real input, so `formState.dirtyFields` and `touchedFields` work 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:

```tsx
<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:

```tsx
<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](https://platform.azmara.io/docs/components/form.md#error-summary). 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](https://platform.azmara.io/docs/components/field-array.md) 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, `onSubmit` gets the parsed output: trimmed strings, defaults and transforms applied. Type the form with `useForm<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-level `refine`, goes to `errors.root`, which the summary shows without a link.
- With `criteriaMode: "all"`, every message per field is kept in `types`.

## `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.

```ts
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 with `aria-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.
