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
Fieldrender-prop over passing a bare<input>aschildren— it's the only way the id/aria-describedbywiring 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/applyServerErrorsexisting. - Don't roll your own spinner/disabled-state logic on a submit button —
SubmitButton'sbusyprop already handlesaria-busy,aria-disabled, and a reduced-motion-aware spinner.