Integrating the Zod Resolver with React Hook Form
This recipe wires @hookform/resolvers/zod’s zodResolver into a useForm call so a single Zod schema drives validation, infers the form’s value types, and maps each ZodError issue onto formState.errors. The result is one source of truth for rules, types, and messages.
When to Use This Recipe
Reach for the resolver — rather than inline register rules — when any of these hold:
- You already validate the same shape on the server and want to share one schema.
- You need cross-field rules (
.refine/.superRefine) that per-field rules cannot express. - You want
z.inferto typeuseForm,handleSubmit, anddefaultValuesautomatically.
For a single field with a trivial required check, inline register rules are lighter. The broader trade-offs live in React Hook Form Validation and the Schema-Based Validation with Zod guide.
Minimal Complete Working Example
The schema is the source of truth. z.infer types the form, zodResolver connects the two, and errors carries one message per field — including the cross-field confirm error attached via path.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useId } from 'react';
import { z } from 'zod';
// 1. One schema: rules + messages + cross-field refinement.
const SignupSchema = z.object({
username: z.string().min(3, 'At least 3 characters').max(20, 'At most 20 characters'),
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'At least 8 characters'),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: 'Passwords do not match',
path: ['confirm'], // attach to the confirm field, not the form root
});
// 2. The form's value type is inferred — no hand-written interface.
type SignupValues = z.infer<typeof SignupSchema>;
export function SignupForm() {
const {
register, handleSubmit, formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(SignupSchema), // 3. wire schema → RHF
mode: 'onBlur',
defaultValues: { username: '', email: '', password: '', confirm: '' },
});
const ids = { username: useId(), email: useId(), password: useId(), confirm: useId() };
const onValid = async (values: SignupValues) => {
// values is fully typed and already validated by the schema.
await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
};
return (
<form noValidate onSubmit={handleSubmit(onValid)}>
{(['username', 'email', 'password', 'confirm'] as const).map((name) => {
const err = errors[name];
const errId = `${ids[name]}-err`;
return (
<div className="form-group" key={name}>
<label htmlFor={ids[name]}>{name}</label>
<input
id={ids[name]}
type={name.includes('password') || name === 'confirm' ? 'password' : 'text'}
aria-invalid={err ? 'true' : undefined}
aria-describedby={err ? errId : undefined}
{...register(name)}
/>
{/* 4. resolver populated errors[name].message from the schema */}
<p id={errId} role="alert" className="error-container">{err?.message}</p>
</div>
);
})}
<button type="submit" disabled={isSubmitting}>Create account</button>
</form>
);
}
Parameter Reference
| Parameter | Type | Purpose |
|---|---|---|
zodResolver(schema) |
Resolver<Values> |
Adapter passed to useForm({ resolver }); runs safeParse |
schema |
z.ZodType |
The Zod schema; its z.infer types the form |
mode |
'onSubmit' | 'onBlur' | 'onChange' | … |
When validation first runs |
path (in .refine) |
(string | number)[] |
Field key the refinement error attaches to |
errors[name].message |
string |
The schema message for that field |
errors[name].type |
string |
The Zod issue code (e.g. too_small) |
second zodResolver arg |
{ mode?, raw? } |
Resolver options; raw: true keeps unparsed values |
Verification Steps
- Type check. Remove a field from
defaultValues— TypeScript should error, provingz.inferflows throughuseForm. This confirms the schema and form share one type. - DevTools. Submit empty; in React DevTools inspect the hook state and confirm
formState.errorshas a key per failing field. In the DOM, confirm each invalid input gainedaria-invalid="true"and anaria-describedbypointing at a populatedrole="alert"node. - Playwright smoke test.
import { test, expect } from '@playwright/test';
test('zod resolver surfaces the mismatch on the confirm field', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('username').fill('alice');
await page.getByLabel('email').fill('alice@example.com');
await page.getByLabel('password').fill('longenough');
await page.getByLabel('confirm').fill('different');
await page.getByRole('button', { name: 'Create account' }).click();
const confirm = page.getByLabel('confirm');
await expect(confirm).toHaveAttribute('aria-invalid', 'true');
await expect(page.getByRole('alert').filter({ hasText: 'Passwords do not match' }))
.toBeVisible();
});
Edge Cases & Failure Modes
Cross-field error lands on the form root, not a field. A bare .refine without path attaches its issue to '' (the root), so errors.confirm stays empty and no message renders. Always pass path: ['confirm'] (or the relevant field) so the resolver can map it.
Coercion silently changes types. If you use z.coerce.number() for a numeric input, the value RHF receives in onValid is a number, but the uncontrolled <input> still holds a string. Type defaultValues from z.infer and let coercion run in the schema; do not also parse in the handler.
Nested object paths. For nested schemas (z.object({ address: z.object({ zip: … }) })), the issue path is ['address', 'zip'] and the error reads as errors.address?.zip. Reference it with optional chaining, and register the field as register('address.zip').
Input Type vs Output Type When the Schema Transforms
The subtlety most teams hit is that a Zod schema has two types, not one, whenever it coerces or transforms. z.infer<T> gives the output type — the shape after parsing — while z.input<T> gives what the schema accepts before transformation. React Hook Form holds the pre-transform values (what the user typed), but your submit handler receives the post-transform result. If you type useForm with only z.infer, defaultValues and register are checked against the output type, which is wrong the moment a field coerces a string into a number or a date.
Recent @hookform/resolvers versions expose the resolver as a generic with three slots so the two types stay distinct end to end:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const EventSchema = z.object({
title: z.string().min(1, 'Required'),
// Text input yields a string; the schema hands your handler a real number.
seats: z.coerce.number().int().min(1, 'At least one seat'),
// datetime-local yields a string; transform normalises it to a Date.
startsAt: z.string().transform((s) => new Date(s)),
});
type EventInput = z.input<typeof EventSchema>; // { title: string; seats: number|string; startsAt: string }
type EventOutput = z.output<typeof EventSchema>; // { title: string; seats: number; startsAt: Date }
// <input, context, output>: RHF fields use EventInput, onValid receives EventOutput.
const form = useForm<EventInput, unknown, EventOutput>({
resolver: zodResolver(EventSchema),
defaultValues: { title: '', seats: 1, startsAt: '' },
});
const onValid = form.handleSubmit((data) => {
// data.startsAt is a Date here, not a string — the transform already ran.
data.startsAt.toISOString();
});
Getting this wrong produces a confusing class of TypeScript errors where defaultValues rejects a plain string for a field whose output is a Date. Reach for the three-generic form whenever the schema contains coerce, transform, pipe, or default, and keep the plain single-generic form only for schemas that pass their input through unchanged.
Re-render Cost and Where Validation Runs
zodResolver re-runs the entire schema on every validation trigger, because safeParse has no concept of a single dirty field. Under mode: 'onChange' that means the full object is re-parsed on each keystroke. For small forms this is imperceptible, but a large schema with several refine calls and network-free but CPU-heavy checks (regex-heavy string validation, large enums) can show up on the flame graph. Two levers control the cost:
- Pick the cheapest mode that meets the UX.
onBlurparses once per field exit;onTouchedwaits for first blur then validates on change;onChangeis the most expensive. Start atonBlurand only escalate a specific field’s feedback if product needs it. - Reduce what re-renders, not just what re-parses. Even a cheap parse triggers a render of every subscribed field. Read
formState.errorsthroughuseFormStateor subscribe per field so an error onemaildoes not re-renderpassword. This is a React subscription concern that is orthogonal to the resolver but compounds with it.
A second, quieter cost is bundle size. Importing z pulls Zod into the client bundle even though the schema may also run on the server. If the same schema validates a request handler, share the module so it is deduplicated rather than defining a parallel client-only copy — the Schema-Based Validation with Zod guide covers structuring a schema for reuse across both tiers.
Surfacing Server Errors Through the Same Channel
Client-side parsing cannot catch everything: a username may be unique only as far as the browser knows. When the server rejects a submission, funnel its field errors back through setError so they render in the exact same role="alert" node the resolver populates, rather than in a separate banner the user has to hunt for:
const onValid = form.handleSubmit(async (values) => {
const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
if (res.status === 409) {
const body: { field: keyof EventInput; message: string } = await res.json();
// Map the server's field error onto the same formState.errors slot.
form.setError(body.field, { type: 'server', message: body.message });
return;
}
});
Because setError writes to formState.errors[field], the aria-invalid and aria-describedby wiring from the worked example lights up automatically — the field does not care whether Zod or the server produced the message. Note that a server-typed error is cleared by the next successful client-side validation of that field, which is usually the behaviour you want: the moment the user edits the offending value, the stale server message disappears.
Frequently Asked Questions
Do I need a separate TypeScript interface for the form values?
No. Derive it with type SignupValues = z.infer<typeof SignupSchema> and pass it
as the useForm<SignupValues> generic. The schema becomes the single source of both
runtime rules and compile-time types.
Why does my .refine error not show on the field?
Without a path, the refinement issue attaches to the form root, so
errors.confirm is empty. Add path: ['confirm'] to the refine options so
zodResolver maps it to that field.
Can I still keep native required attributes with a resolver?
Yes — keep them as a server-rendered first pass and add noValidate on the form so the
browser popup is suppressed while the Zod messages render. The schema remains the authoritative rule
set once React hydrates.
Why does defaultValues reject a value my schema clearly accepts?
You are almost certainly typing useForm with z.infer (the output type) while a
field coerces or transforms. Switch to the three-generic form
useForm<z.input<T>, unknown, z.output<T>> so defaultValues
and register are checked against the pre-parse input while your submit handler still receives
the transformed output.
How do I clear a Zod error after an async server check passes?
Use setError(field, { type: 'server', message }) to attach a server-side failure to the same
formState.errors slot the resolver uses. React Hook Form clears a manually set error the next time
that field passes client validation, so editing the offending value removes the stale message without any
explicit clearErrors call.
Related Guides
- React Hook Form Validation — the full useForm lifecycle this recipe plugs into
- React Hook Form Async Field Validation — adding debounced async checks alongside the schema
- Schema-Based Validation with Zod — building the schema the resolver runs
- Using Zod for Complex Form Schemas — refinements and nested shapes