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

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.

← Back to React Hook Form Validation