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').
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.
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