Pairing React Hook Form with Zod through zodResolver takes three lines, and then the problems start: number fields fail with “Expected number, received string”, a password-confirmation error never appears because it has no path, and TypeScript insists the submitted data is the raw strings rather than the parsed values.

Each problem comes from the boundary between the two libraries: React Hook Form collects input values from the DOM, Zod turns them into output values, and the resolver converts Zod issues into React Hook Form’s error object. This page wires that boundary deliberately. It complements the library-agnostic Zod schema integration and the architecture in React form hook architecture.


Context and prerequisites

What the resolver does, in order:

  1. React Hook Form gathers the current values (strings from text inputs, booleans from checkboxes, FileList from file inputs).
  2. zodResolver(schema) calls schema.safeParseAsync(values) (or the sync variant with { mode: "sync" }).
  3. On success, the parsed output is passed to your handleSubmit callback — transformed, coerced, trimmed.
  4. On failure, each Zod issue’s path becomes a key in formState.errors (["address", "city"] → errors.address.city), with the issue’s message.

So the schema must accept what the DOM produces, the form’s TypeScript types must distinguish input from output, and every refinement must put its issue on a path that a rendered field reads.

From DOM strings to typed submit data Inputs hold strings, booleans and file lists. React Hook Form collects them into the input values object. The zodResolver runs the schema's parse, coercing and transforming as the schema declares. On success the output values, with numbers as numbers and trimmed strings, reach handleSubmit. On failure each issue's path becomes a key in formState.errors, and only issues whose path matches a registered field name are displayed next to that field. DOM inputs strings, booleans, FileList Whatever the elements produce. RHF input values z.input<typeof Schema> Typed as what the form holds, not what you submit. zodResolver parse coerce, trim, transform, refine Async parse by default; sync if the schema is sync. handleSubmit(output) or errors z.output<typeof Schema> | errors[path] Issues whose path matches no field are invisible.

The core pattern: typed input and output, coercion, pathed refinements

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const Signup = z.object({
  email: z.string().trim().toLowerCase().email("Enter an email like [email protected]."),
  // DOM gives strings: coerce explicitly, and treat "" as missing rather than 0.
  age: z.preprocess((v) => (v === "" ? undefined : v),
    z.coerce.number({ invalid_type_error: "Enter your age as a number." }).int().min(16, "You must be 16 or over.")),
  password: z.string().min(12, "Use at least 12 characters."),
  confirmPassword: z.string(),
}).superRefine((v, ctx) => {
  if (v.password !== v.confirmPassword) {
    // Put the issue on the field the user must change, or it will never render.
    ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["confirmPassword"], message: "Passwords do not match." });
  }
});

type SignupInput = z.input<typeof Signup>;    // what the form holds
type SignupOutput = z.output<typeof Signup>;  // what handleSubmit receives

export function SignupForm({ onValid }: { onValid: (data: SignupOutput) => Promise<void> }) {
  const { register, handleSubmit, formState: { errors, isSubmitting } } =
    useForm<SignupInput, unknown, SignupOutput>({
      resolver: zodResolver(Signup),
      mode: "onTouched",          // first error after blur…
      reValidateMode: "onChange", // …then clear as soon as it is fixed
      defaultValues: { email: "", age: "" as unknown as number, password: "", confirmPassword: "" },
    });

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" {...register("email")}
        aria-invalid={errors.email ? true : undefined} aria-describedby={errors.email ? "email-error" : undefined} />
      {errors.email && <p id="email-error">{errors.email.message}</p>}

      <label htmlFor="age">Age</label>
      <input id="age" inputMode="numeric" {...register("age")} aria-invalid={errors.age ? true : undefined} />
      {errors.age && <p id="age-error">{errors.age.message}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <label htmlFor="confirmPassword">Confirm password</label>
      <input id="confirmPassword" type="password" {...register("confirmPassword", { deps: ["password"] })} />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type="submit" aria-disabled={isSubmitting}>Create account</button>
    </form>
  );
}

Step-by-step walkthrough

  1. Write the schema for DOM input. Text inputs produce strings; coerce numbers and dates explicitly, and convert "" to undefined so an empty optional field is not parsed as 0 — detailed in coercing form strings with Zod preprocess and coerce.
  2. Type the form with three generics. useForm<Input, Context, Output> makes register and defaultValues use input types and handleSubmit receive parsed output. Without it, TypeScript either complains about string defaults or lies about submitted types.
  3. Give every refinement a path. Object-level superRefine issues default to the root path (""), which React Hook Form stores as errors.root-ish keys no field reads. Set path to the field the user should change.
  4. Re-run dependent fields. register("confirmPassword", { deps: ["password"] }) revalidates confirmation when the password changes, so a fixed mismatch clears — the cross-field pattern from password confirmation validation.
  5. Choose modes that match your timing policy. mode: "onTouched" with reValidateMode: "onChange" is reward-early, punish-late; avoid mode: "onChange" with async refinements, which would call the server per keystroke.
  6. Keep async refinements cheap and cancellable. Zod has no cancellation; a slow uniqueness check inside the schema runs on every validation. Prefer a separate debounced field-level check, as in async refinements for remote checks with Zod.
Resolver pitfalls and their fixes A number field fails with expected number, received string because the DOM produces strings; coerce in the schema. An optional number becomes zero when empty; preprocess empty strings to undefined. A superRefine error never shows because it has no path; add a path to the field. handleSubmit data is typed as strings; use the three useForm generics. An async refinement fires a request per keystroke; use onTouched mode or move the check out of the schema. Symptom Cause Fix "Expected number, received string" DOM yields strings z.coerce.number() empty optional becomes 0 Number("") is 0 preprocess "" → undefined cross-field error never shows issue has no path ctx.addIssue({ path: [field] }) submit data typed as strings one generic only useForm<In, Ctx, Out> request per keystroke async refine + onChange mode onTouched, or check outside schema

Reusing the schema beyond the resolver

The same schema that drives the resolver can do more work. Export its inferred output type and use it for the API client, so the request body is type-checked against what the form produces. Import it in the server route or server action and parse the request body again, so a client that bypasses the form still meets the same rules. And derive defaults and field metadata from it where helpful — whether a field is optional, its maximum length — so labels (“optional”) and maxLength attributes cannot drift from validation. The resolver is one consumer of the schema, not its owner.


Failure modes and edge cases

1. valueAsNumber plus coercion

register("age", { valueAsNumber: true }) makes React Hook Form pass NaN for empty input. Combined with z.coerce.number(), NaN fails with a type message instead of “required”. Use one mechanism — schema coercion is easier to reason about.

2. Checkbox groups and FileList

Several checkboxes registered under one name yield an array of values, or false when none are checked; file inputs yield a FileList, not an array. Model both in the schema (z.array(z.string()) with a preprocess for false; z.instanceof(FileList) then .transform((l) => Array.from(l))).

3. Errors for array items

Issues inside arrays have numeric path segments (["items", 2, "price"]), which React Hook Form maps to errors.items[2].price. With useFieldArray, render using field.id as the key and read errors by index at render time — they move with the library’s own move/remove operations.

4. Server errors after a successful parse

The resolver only covers client validation. Map server 422s onto fields with setError("email", { type: "server", message }), and clear them when the field changes, as in clearing server errors when a field changes.

5. Focus on submit errors

shouldFocusError: true (the default) focuses the first field with an error, in registration order. That is often right; if you render an error summary, set it to false and focus the summary yourself.

Which mode pairs with which schema For purely synchronous schemas, onTouched with reValidateMode onChange gives reward-early, punish-late feedback at negligible cost. For schemas with an async refinement, use onTouched or onSubmit so the remote check does not run per keystroke. For very large schemas where full parsing is expensive, use onSubmit plus field-level checks for instant feedback. Sync schema mode: onTouched reValidateMode: onChange Async refinement mode: onTouched or onSubmit Never onChange. Very large schema mode: onSubmit Field-level checks for instant feedback.

Verification checklist


Frequently Asked Questions

Does zodResolver validate the whole form on every change?

Yes — a resolver validates the entire schema and React Hook Form then updates errors for the relevant fields. For typical forms this is fast. For very large schemas, prefer submit-time resolver validation and cheap field-level validate functions for instant feedback.

Should defaultValues match the input or output type?

The input type: they populate the DOM. With the three-generic useForm, TypeScript enforces that. A numeric field’s default is usually "" in the input type, which the schema turns into a number or undefined on parse.

Can I use Valibot or another library the same way?

Yes. @hookform/resolvers provides resolvers for Valibot, Yup, ArkType and others, plus a Standard Schema resolver that accepts any compliant library. The input/output typing and path discipline on this page apply to all of them.


Related

← React Form Hook Architecture