Forms with React Hook Form + Zod
Pathrule2 Rules • 2 Memories • 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.
Rules
2Zod schema is the single source of truth/src/lib/schemashighstrictDerive form types from the schema; never hand-write a parallel interface.
| 1 | Each form has exactly one Zod schema, and every type comes 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 has `.transform()`, `.default()`, or coercion, type `useForm` values with `z.input<typeof schema>` and the parsed result with `z.output<typeof schema>` because they diverge. |
| 5 | - Export the schema from `/src/lib/schemas` so client forms and server handlers import the same object. |
| 6 | - Keep messages in the schema (`z.string().min(1, 'Required')`) so client and server emit identical errors. |
Wire useForm with a resolver and full defaultValues/src/components/formshighadvisoryEvery form passes the Zod resolver plus a complete defaultValues object.
| 1 | Every `useForm` call is resolver-backed and fully initialized. |
| 2 | |
| 3 | - Pass `resolver: zodResolver(schema)` from `@hookform/resolvers/zod`; it auto-detects Zod 3 vs 4, so no version branching. |
| 4 | - Provide a complete `defaultValues` object covering every field so inputs stay controlled-from-the-start and React never warns about controlled/uncontrolled flips. |
| 5 | - Default `mode` is fine for submit-time validation; only opt into `mode: 'onChange'` or `onBlur` when the UX needs it, since it costs re-renders. |
| 6 | - Keep inputs uncontrolled via `register` or `Controller`; do not mirror field values into local `useState`. |
Memories
2Re-parse with the same schema in Server Actions/appServer handlers re-validate with the shared schema before any write.
| 1 | Client validation is a UX affordance, not a security boundary, so the server re-parses. |
| 2 | |
| 3 | - In a Server Action or API route, call `schema.safeParse(input)` using the exact schema the form imports; never trust the payload because RHF already validated it. |
| 4 | - Return `result.error.flatten()` field errors and map them back onto the form with `setError` so server failures surface inline, not just as a toast. |
| 5 | - Combine with `useActionState` (React 19) to thread pending state and returned errors through the action without extra client state. |
| 6 | - Coerce and sanitize on the server side via the schema (`z.coerce.number()`, `.trim()`) rather than re-implementing checks by hand. |
Async submit, errors, and reset patterns/src/components/formsDrive submission UX from formState and reset with server-confirmed values.
| 1 | Submission UX reads from `formState`, never from a separate loading flag. |
| 2 | |
| 3 | - Gate the submit button on `formState.isSubmitting` and surface validity with `isValid` / `isDirty` instead of tracking booleans manually. |
| 4 | - Throwing inside the `handleSubmit` async callback is fine; catch it to map a server error with `setError('root.serverError', ...)`. |
| 5 | - After a successful save, call `reset(serverConfirmedValues)` so the form's dirty baseline matches what was persisted. |
| 6 | - For dependent or cross-field rules use `.refine()` / `.superRefine()` in the schema, and read live values with `watch` only where a render genuinely depends on them. |
Skills
1forms-rhf-zod-review/rootPre-merge checklist for React Hook Form + Zod forms.
| 1 | --- |
| 2 | name: forms-rhf-zod-review |
| 3 | description: Review checklist for forms built with React Hook Form and a Zod resolver. Run before merging any new or changed form to confirm schema-first typing, a complete defaultValues object, and server-side re-validation parity. |
| 4 | --- |
| 5 | |
| 6 | # Forms with 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`. |
| 10 | - [ ] `defaultValues` covers every field so no input flips between controlled and uncontrolled. |
| 11 | - [ ] Validation `mode` matches the intended UX; `onChange` is justified, not the default copy-paste. |
| 12 | - [ ] Inputs use `register`/`Controller`; field values are not duplicated into local `useState`. |
| 13 | - [ ] The server (Server Action or API route) re-parses with the same schema via `safeParse` before any write. |
| 14 | - [ ] Server field errors are mapped back with `setError`; the form shows inline errors, not only a toast. |
| 15 | - [ ] Submit UX reads `formState.isSubmitting` / `isValid` rather than a separate loading flag. |
| 16 | - [ ] Successful submit calls `reset(serverConfirmedValues)` to refresh the dirty baseline. |
| 17 | - [ ] Cross-field rules live in `.refine()`/`.superRefine()`, 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