Forms with React Hook Form + Zod

Pathrule3 Rules • 1 Memory • 1 Skill

A pattern for building forms with React Hook Form and a Zod resolver where one schema is the single source of truth for types, client validation, and server validation. It keeps inputs uncontrolled for performance, infers TypeScript types directly from the schema, and reuses the same schema in Server Actions or API routes so client and server never drift. Built for Zod 4 and React Hook Form 7.

Suggested path map

Pathrule places each piece on the matching path, so your assistant only sees it where it belongs. This is the scoping you get on import; you can adjust it in your workspace.

/ workspace root
forms-rhf-zod-review
src/
lib/
schemas/
Zod schema is the single source of truth for form types
components/
forms/
Wire useForm with the resolver, three generics, and full defaultValues
Submission, server errors, and reset are driven from formState
app/
Server must re-parse with the same schema before any write

Rules

3
Zod schema is the single source of truth for form types/src/lib/schemashighstrictDerive every form type from the schema; never hand-write a parallel interface.
1Each form has exactly one Zod schema, and every type is derived from it.
2 
3- Infer types with `z.infer<typeof schema>`; never declare a parallel `interface` or `type` for the same shape.
4- When the schema uses `.transform()`, `.default()`, `.pipe()`, or `z.coerce.*`, the input and output types diverge. Keep both available: `z.input<typeof schema>` is what the form fields hold, `z.output<typeof schema>` is what the submit handler receives after validation. Do not collapse them into one type.
5- Export the schema from `/src/lib/schemas` so client forms and server handlers import the same object, not two copies.
6- Put messages in the schema (`z.string().min(1, 'Required')`) so client and server emit identical errors.
7- Cross-field rules belong in the schema via `.refine()` or `.superRefine()` (`.superRefine` is supported again as of Zod 4.x and was un-deprecated in 2025). Note `ctx.path` was removed in Zod 4: pass an explicit `path` array to `ctx.addIssue({ code: 'custom', path: ['confirmPassword'], message: '...' })`.
Wire useForm with the resolver, three generics, and full defaultValues/src/components/formshighadvisoryResolver-backed forms type useForm with input/context/output and a complete defaultValues object.
1Every `useForm` call is resolver-backed, correctly typed, and fully initialized. This assumes `@hookform/resolvers` v5 (requires `react-hook-form` >= 7.55).
2 
3- Pass `resolver: zodResolver(schema)` from `@hookform/resolvers/zod`. One resolver handles Zod 3, Zod 4, and Zod 4 mini, so no version branching is needed.
4- When the schema has transforms/coercion/defaults, use the three-generic signature so the submit handler sees the parsed output type: `useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>({ resolver: zodResolver(schema), defaultValues })`. Omitting the third generic is the most common v5 type error: `handleSubmit` data is then typed as the input, not the transformed output.
5- Provide a complete `defaultValues` object covering every field so inputs are controlled from first render and React never warns about a controlled/uncontrolled flip.
6- Default (submit-time) `mode` is correct for most forms; only opt into `mode: 'onChange'` or `'onBlur'` when the UX needs it, since per-keystroke validation costs re-renders.
7- Keep inputs uncontrolled via `register` or `Controller`; do not mirror field values into local `useState`.
8- If you standardize on Standard Schema across libraries, `standardSchemaResolver` is the alternative, but prefer `zodResolver` for Zod-only projects.
Server must re-parse with the same schema before any write/apphighstrictClient validation is UX only; the server re-parses the shared schema and never trusts the payload.
1Client-side validation is a UX affordance, not a security boundary. The server re-validates on every mutation.
2 
3- In a Server Action or API route, call `schema.safeParse(input)` using the exact schema the form imports. Never skip this because React Hook Form already validated, and never trust the raw payload.
4- On failure, build field errors with `z.flattenError(result.error)` (Zod 4). The instance method `result.error.flatten()` is deprecated in Zod 4. `z.flattenError` returns `{ formErrors, fieldErrors }`; use `z.treeifyError` for nested schemas.
5- Coerce and sanitize on the server through the schema (`z.coerce.number()`, `.trim()`) rather than re-implementing checks by hand, so server and client stay in lockstep.
6- Return field errors to the client so they can be mapped back inline with `setError`, instead of swallowing them into a generic toast.

Memories

1
Submission, server errors, and reset are driven from formState/src/components/formsDrive submit UX from formState, map server errors with setError, reset to server-confirmed values.
1Submission UX reads from `formState`, never from a parallel loading flag we maintain ourselves.
2 
3- Gate the submit button on `formState.isSubmitting`, and surface readiness with `isValid` / `isDirty`, rather than tracking booleans manually.
4- Pair the form with `useActionState` (React 19) so pending state and the action's returned errors thread through without extra client state. The Server Action returns the `z.flattenError` field errors and we map them on the client.
5- Map server-side failures back onto fields with `setError`, and use `setError('root.serverError', { message })` for non-field errors so they render inline, not only as a toast.
6- Throwing inside the async `handleSubmit` callback is acceptable; catch it and convert to a `setError` call.
7- After a successful save, call `reset(serverConfirmedValues)` so the dirty baseline matches what was actually persisted (avoids a form that still looks dirty post-save).
8- Read live values with `watch` only where a render genuinely depends on them; prefer `getValues` for one-off reads inside handlers to avoid re-render churn.

Skills

1
forms-rhf-zod-review/rootPre-merge checklist for React Hook Form + Zod forms (resolvers v5, Zod 4).
1---
2name: forms-rhf-zod-review
3description: Review checklist for forms built with React Hook Form and a Zod resolver, targeting @hookform/resolvers v5 and Zod 4. Run before merging any new or changed form to confirm schema-first typing, correct useForm generics, a complete defaultValues object, and server-side re-validation parity.
4---
5 
6# React Hook Form + Zod review
7 
8- [ ] One Zod schema is the source of truth; types come from `z.infer` (or `z.input` / `z.output` when transforms diverge), not a hand-written interface.
9- [ ] `useForm` uses `resolver: zodResolver(schema)` from `@hookform/resolvers/zod` (v5, with `react-hook-form` >= 7.55).
10- [ ] When the schema has transforms/coercion/defaults, `useForm` uses the three-generic form `useForm<z.input<...>, unknown, z.output<...>>` so the submit handler sees the parsed output type.
11- [ ] `defaultValues` covers every field so no input flips between controlled and uncontrolled.
12- [ ] Validation `mode` matches the intended UX; `onChange`/`onBlur` is a deliberate choice, not copy-paste.
13- [ ] Inputs use `register` / `Controller`; field values are not duplicated into local `useState`.
14- [ ] The server (Server Action or API route) re-parses with the same schema via `safeParse` before any write.
15- [ ] Server field errors are built with `z.flattenError(error)` (not the deprecated `error.flatten()`), returned, and mapped back with `setError`; errors show inline, not only as a toast.
16- [ ] Submit UX reads `formState.isSubmitting` / `isValid`; pending state uses `useActionState` where applicable, not a hand-rolled loading flag.
17- [ ] Successful submit calls `reset(serverConfirmedValues)` to refresh the dirty baseline.
18- [ ] Cross-field rules live in `.refine()` / `.superRefine()` with an explicit `path` (Zod 4 removed `ctx.path`), not in component effects.

Why this pattern

Form types, client validation, and server validation drift apart and re-render the whole form on every keystroke.

Built for Frontend and full-stack teams building React or Next.js forms with TypeScript.

Keeps your assistant from:

  • Hand-writing TypeScript types that drift from the Zod schema instead of inferring them
  • Trusting client-side validation only and skipping a server-side re-parse
  • Controlling every input with local useState and re-rendering the whole form on each keystroke
License
Apache-2.0
Version
1.0.0
Updated
2026-06-09
View source