Design System
Patterns & Recipes

How do I build a form?

React Hook Form + Zod, via @digital-web-platform/forms.

Prescribed: React Hook Form + Zod, via @digital-web-platform/forms's useZodForm hook and its field primitives (Field, Input, Textarea, Select, RadioGroup, ErrorText, FormStatus, SubmitButton). Don't reach for a different form library, and don't hand-wire <input> + useState + your own validation — every piece below (label association, aria-describedby, aria-invalid, the required-field cue, busy/disabled submit state) is already solved and accessible.

This package currently has one real, production consumer — digital-gp (23+ files: login, guest details, dietary/accessibility forms, and their server-side schemas) — not yet cross-app proven, but it is genuinely battle-tested, not a sketch.

The approved implementation

useZodForm is a thin wrapper around React Hook Form's useForm — pass a Zod schema instead of a resolver, and it returns the real, unmodified UseFormReturn. Wrap each control in Field using the render-prop form (not a bare element) so the id/aria-describedby/aria-invalid wiring can't drift from the control:

The server side

Field/Input/etc. handle client-side validation, but a real form also needs to handle server-rejected input (the client and server schemas should be the same Zod schema, imported from a shared @digital-web-platform/forms/schema-style module, not duplicated by hand). This package's serverParse + applyServerErrors close that loop — parse the request body with the shared schema in your route handler, and feed a failure straight into the form's setError:

// app/api/<feature>/route.ts (server)
const parsed = serverParse(ContactSchema, await request.json());
if (!parsed.success) {
  return apiValidationError(parsed.formError, parsed.fieldErrors);
}

// the form component (client), on a failed submit response
applyServerErrors(setError, { formError, fieldErrors });

No production route handler exercises this exact contact-form schema yet in this repo — the snippet above is illustrative, shaped by the package's own tests and its real usage in digital-gp's login/guest-detail forms, not copied from a single working file.

Do / Don't

  • Do prefer the Field render-prop over passing a bare <input> as children — it's the only way the id/aria-describedby wiring is guaranteed to stay correct as the form changes.
  • Do share one Zod schema between the client form and the server route handler — that's the whole point of serverParse/applyServerErrors existing.
  • Don't roll your own spinner/disabled-state logic on a submit button — SubmitButton's busy prop already handles aria-busy, aria-disabled, and a reduced-motion-aware spinner.

On this page