Zod 18 🛡️ React Hook Form Integration — zodResolver setup and useForm
React Hook Form’s built-in validation rules live in the register call. Zod’s validation lives in the schema. The zodResolver connects them: the form fields register as usual, but validation is delegated to the schema, and the errors that appear in formState.errors are the schema’s issues mapped to field names. This means the rules are defined once, in a file that can be shared with the server, and the JSX only displays the messages.
This chapter covers the installation, the resolver setup, the typing patterns that determine whether the form is type-safe, and the behavioral rules that change when a resolver is present. The sections that follow walk through the resolver configuration, the type inference from schema to form, and the cross-field validation patterns that require deps.
Key point: With a resolver, register‘s built-in rules do not run—every rule must be in the schema—and the form’s types are inferred from the schema when useForm is called without a generic, or explicitly provided via z.infer when the schema has transforms or coercions.
a. Installation and resolver setup
The integration requires three packages: React Hook Form, Zod, and the resolver package. The resolver is installed separately because it is maintained in the react-hook-form/resolvers repository, not bundled with the core library .
npm install react-hook-form zod @hookform/resolvers
The resolver is imported from the /zod subpath, not from the package root. The useForm call accepts resolver: zodResolver(schema) alongside defaultValues and the validation mode .
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const SignUpSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters."),
email: z.email("Enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
});
type SignUpValues = z.infer<typeof SignUpSchema>;
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignUpValues>({
resolver: zodResolver(SignUpSchema),
defaultValues: { name: "", email: "", password: "" },
mode: "onTouched",
});
const onSubmit = (values: SignUpValues) => {
// values are typed and validated
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
{/* ... */}
</form>
);
}
The noValidate attribute on the <form> element disables the browser’s native validation, which would otherwise intercept invalid inputs before the schema sees them. The schema’s messages are the ones that should appear, and native validation produces different messages with different timing .
b. Type inference and the generic question
The useForm generic is optional when the schema has no transforms or coercions. The resolver infers the field names and the error types from the schema, and a typo like register("emial") is caught at compile time . This is the preferred pattern because it removes the second declaration and lets the schema be the single source of truth.
// No generic: types inferred from the resolver
const form = useForm({
resolver: zodResolver(SignUpSchema),
});
// register("emial") → compile error
The generic is required when the schema’s input and output types diverge. A schema with .transform() holds the input type in the form and produces the output type in handleSubmit. A schema with z.coerce infers the input as unknown and the output as the target type, which produces a type error when the generic is set to z.infer .
// Transforms: explicit input/output types
const Schema = z.object({
age: z.string().transform((s) => parseInt(s, 10)),
});
type FormInput = z.input<typeof Schema>; // { age: string }
type FormOutput = z.infer<typeof Schema>; // { age: number }
useForm<FormInput, unknown, FormOutput>({
resolver: zodResolver(Schema),
});
The three-generic form is the correct pattern when the schema transforms. The first generic is what the form holds; the third is what handleSubmit receives. The middle is the transformed values type, which is unknown unless the resolver uses the raw option .
c. Cross-field validation and deps
A schema refinement that compares two fields—password and confirmation—produces an error at a path specified in the refinement. The form displays the error at that path, but React Hook Form only re-validates the field that changed. If the user changes password, the refinement’s error on confirmPassword does not update because confirmPassword was not the field that changed.
The deps option on register tells React Hook Form to re-validate the listed fields when the current field validates .
const Schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
error: "Passwords do not match.",
path: ["confirmPassword"],
});
// In the form:
<input {...register("password", { deps: "confirmPassword" })} />
The deps connection is one-directional: when password validates, confirmPassword is also validated. The refinement itself is attached to the object schema, not to either field, so the error appears at the path the refinement specifies .
The behavioral rules that change with a resolver are worth listing explicitly. The register options like { required: true } are ignored; the schema is the only validation source. A form-level validate function in useForm is also ignored; the resolver takes precedence. And the error messages must be written in the schema, not in the JSX .
Complete Example Session
// ============================================
// PART 1: THE SCHEMA
// ============================================
import { z } from "zod";
export const SignUpSchema = z.object({
name: z.string().trim().min(2, "Name must be at least 2 characters."),
email: z.email("Enter a valid email address."),
password: z.string().min(8, "Password must be at least 8 characters."),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
error: "Passwords do not match.",
path: ["confirmPassword"],
});
export type SignUpValues = z.infer<typeof SignUpSchema>;
// ============================================
// PART 2: THE FORM
// ============================================
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(SignUpSchema),
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
mode: "onTouched",
});
const onSubmit = (values: SignUpValues) => {
// values are typed and validated
createAccount(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<label htmlFor="name">Name</label>
<input id="name" {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password", { deps: "confirmPassword" })} />
{errors.password && <p>{errors.password.message}</p>}
<label htmlFor="confirmPassword">Confirm password</label>
<input id="confirmPassword" type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit">Sign up</button>
</form>
);
}
// ============================================
// PART 3: THE FIELD ARRAY (DYNAMIC FIELDS)
// ============================================
import { useFieldArray } from "react-hook-form";
const ContactsSchema = z.object({
contacts: z.array(z.object({
name: z.string().min(1, "Name is required."),
email: z.email("Invalid email."),
})).min(1, "At least one contact is required."),
});
function ContactsForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(ContactsSchema),
defaultValues: { contacts: [{ name: "", email: "" }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "contacts" });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}> {/* field.id, not index */}
<input {...register(`contacts.${index}.name`)} />
<input {...register(`contacts.${index}.email`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", email: "" })}>Add</button>
<button type="submit">Submit</button>
</form>
);
}
The parts covered the schema, the resolver-based form with deps for cross-field validation, and the field array pattern that uses field.id as the render key.
Quick Reference
Setup
| Package | Import |
|---|---|
| React Hook Form | useForm, useFieldArray |
| Zod | z |
| Resolver | zodResolver from @hookform/resolvers/zod |
useForm Configuration
| Option | Value |
|---|---|
resolver | zodResolver(Schema) |
defaultValues | Object matching schema input |
mode | "onSubmit", "onChange", "onBlur", "onTouched" |
Resolver Behavior
| Behavior | Detail |
|---|---|
register rules | Ignored |
Form-level validate | Ignored |
| Error messages | From schema |
| Typing | Inferred from resolver (no generic) |
Cross-Field Validation
| Requirement | Detail |
|---|---|
| Refinement path | path: ["fieldName"] |
deps option | register("field", { deps: "otherField" }) |
| Re-validation | Triggered when the dependent field changes |
Best Practices
✅ Do This:
// Install all three packages
npm install react-hook-form zod @hookform/resolvers // ✅
// Use the resolver without a generic
useForm({ resolver: zodResolver(Schema) }) // ✅
// Use deps for cross-field validation
register("password", { deps: "confirmPassword" }) // ✅
// Use field.id as the key in field arrays
fields.map((field) => <div key={field.id} />) // ✅
❌ Don’t Do This:
// Mix register rules with resolver
register("name", { required: true }) // ignored // ❌
// Use index as key in field arrays
fields.map((_, index) => <div key={index} />) // ❌
// Omit noValidate on the form
<form onSubmit={handleSubmit(onSubmit)}> // ❌
// Set the generic to z.infer with a coercing schema
useForm<z.infer<typeof CoerceSchema>>({ ... }) // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
register rules not firing | Resolver overrides them | Put all rules in schema |
| Cross-field error not updating | No deps on the changed field | Add deps: "otherField" |
Type error with z.coerce | Input type is unknown | Use useForm<z.input<>, unknown, z.infer<>> |
| Field array rows not reordering | index used as key | Use field.id |
| Browser validation intercepts | No noValidate | Add noValidate to <form> |
Real-World Examples
1. Basic resolver setup
useForm({ resolver: zodResolver(Schema) });
2. Cross-field deps
register("password", { deps: "confirmPassword" });
3. Field array with field.id
fields.map((field) => <div key={field.id} />);
4. No generic (inferred)
useForm({ resolver: zodResolver(Schema) });
5. Transform input/output
useForm<z.input<typeof S>, unknown, z.infer<typeof S>>({ ... });
6. Async refinement with debounce
trigger("username"); // after debounce
7. Server error injection
setError("root", { type: "server", message: "..." });
8. Multi-step validation
const isValid = await trigger(["name", "email"]);
9. Conditional schema
z.discriminatedUnion("type", [A, B]);
10. Large form performance
// Read formState inline, not destructured
if (!form.formState.isValid) return;
Visual
The Resolver Connection
┌─────────────────────────────────────────────────────────┐
│ Schema (Zod) │
│ ┌──────────────────────────────────────────┐ │
│ │ z.object({ email: z.email(), ... }) │ │
│ └──────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ zodResolver(Schema) │
│ │ │
│ ▼ │
│ useForm({ resolver }) │
│ │ │
│ ▼ │
│ formState.errors ← schema issues mapped to fields │
│ handleSubmit ← validated, typed output │
└─────────────────────────────────────────────────────────┘
Cross-Field deps
┌─────────────────────────────────────────────────────────┐
│ Schema refinement: │
│ password === confirmPassword │
│ path: ["confirmPassword"] │
│ │
│ User changes password │
│ │ │
│ ▼ │
│ RHF validates password │
│ │ │
│ ▼ │
│ deps: "confirmPassword" → re-validates confirm │
│ │ │
│ ▼ │
│ Refinement runs → error appears at confirmPassword │
└─────────────────────────────────────────────────────────┘
Type Inference
┌─────────────────────────────────────────────────────────┐
│ NO GENERIC GENERIC REQUIRED │
│ │
│ No transforms Has .transform() or │
│ No coercions z.coerce() │
│ │ │ │
│ ▼ ▼ │
│ useForm({ resolver }) useForm< │
│ Types inferred z.input<typeof S>, │
│ from schema unknown, │
│ z.infer<typeof S> │
│ >({ resolver }) │
└─────────────────────────────────────────────────────────┘
Field Array Key
┌─────────────────────────────────────────────────────────┐
│ fields from useFieldArray: │
│ [{ id: "a1b2", name: "" }, { id: "c3d4", name: "" }] │
│ │
│ key={field.id} ✓ React tracks by identity │
│ key={index} ✗ React tracks by position │
│ │
│ Reorder rows → field.id keeps values with their row │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Packages | react-hook-form, zod, @hookform/resolvers |
| Resolver import | zodResolver from @hookform/resolvers/zod |
useForm option | resolver: zodResolver(Schema) |
register rules | Ignored with resolver |
| Cross-field validation | deps: "otherField" |
| Field array key | field.id |
| Transform typing | useForm<z.input<>, unknown, z.infer<>> |
| Coercion typing | Same three-generic pattern |
Key takeaways:
- The resolver makes the schema the validation source. When
zodResolveris present,register‘s built-in rules anduseForm‘s form-levelvalidateare ignored. Every rule must be in the schema . - Types are inferred when no transform exists. Calling
useFormwithout a generic lets the resolver infer the field names and error types, catching typos at compile time . - Transforms and coercions require the three-generic form. The form holds the input type;
handleSubmitreceives the output type.z.coerceinfers the input asunknown, which produces a type error if the generic is set toz.inferalone . - Cross-field validation needs
deps. The refinement’s error appears at its path, but React Hook Form only re-validates the changed field. Thedepsoption re-validates the other field when the current one changes . - Field arrays use
field.id, notindex. Theidis the stable identity that React uses to track rows; theindexchanges on reorder and causes values to swap between inputs.
Remember: The zodResolver is the bridge between the schema and the form. It maps schema issues to field errors, types the form from the schema, and delegates all validation to the schema. The cost is that register‘s rules stop working—the schema is the only place rules can live. The benefit is that the rules are defined once, shared with the server, and the messages flow into formState.errors without manual wiring. Use deps for cross-field checks, field.id for dynamic lists, and the three-generic form when the schema transforms.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!