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:
- React Hook Form gathers the current values (strings from text inputs, booleans from checkboxes,
FileListfrom file inputs). zodResolver(schema)callsschema.safeParseAsync(values)(or the sync variant with{ mode: "sync" }).- On success, the parsed output is passed to your
handleSubmitcallback — transformed, coerced, trimmed. - On failure, each Zod issue’s
pathbecomes a key informState.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.
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
- Write the schema for DOM input. Text inputs produce strings; coerce numbers and dates explicitly, and convert
""toundefinedso an empty optional field is not parsed as0— detailed in coercing form strings with Zod preprocess and coerce. - Type the form with three generics.
useForm<Input, Context, Output>makesregisteranddefaultValuesuse input types andhandleSubmitreceive parsed output. Without it, TypeScript either complains about string defaults or lies about submitted types. - Give every refinement a path. Object-level
superRefineissues default to the root path (""), which React Hook Form stores aserrors.root-ish keys no field reads. Setpathto the field the user should change. - 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. - Choose modes that match your timing policy.
mode: "onTouched"withreValidateMode: "onChange"is reward-early, punish-late; avoidmode: "onChange"with async refinements, which would call the server per keystroke. - 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.
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.
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.