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.infer to type useForm, handleSubmit, and defaultValues automatically.

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.

zodResolver mapping pipeline Form values flow into safeParse via zodResolver. Success yields typed values for the submit handler. Failure yields a ZodError whose issues are mapped by path into formState.errors. form values zodResolver safeParse success → typed values onValid(data) error → issues by path formState.errors
zodResolver runs safeParse: success forwards typed values to the submit handler; failure maps each issue's path to a field in formState.errors.

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

  1. Type check. Remove a field from defaultValues — TypeScript should error, proving z.infer flows through useForm. This confirms the schema and form share one type.
  2. DevTools. Submit empty; in React DevTools inspect the hook state and confirm formState.errors has a key per failing field. In the DOM, confirm each invalid input gained aria-invalid="true" and an aria-describedby pointing at a populated role="alert" node.
  3. 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. onBlur parses once per field exit; onTouched waits for first blur then validates on change; onChange is the most expensive. Start at onBlur and 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.errors through useFormState or subscribe per field so an error on email does not re-render password. 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.

← Back to React Hook Form Validation