| | |

Zod 18 🛡️ React Hook Form Integration — zodResolver setup and useForm

⏱️ Estimated read: 11 min | |

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

PackageImport
React Hook FormuseForm, useFieldArray
Zodz
ResolverzodResolver from @hookform/resolvers/zod

useForm Configuration

OptionValue
resolverzodResolver(Schema)
defaultValuesObject matching schema input
mode"onSubmit", "onChange", "onBlur", "onTouched"

Resolver Behavior

BehaviorDetail
register rulesIgnored
Form-level validateIgnored
Error messagesFrom schema
TypingInferred from resolver (no generic)

Cross-Field Validation

RequirementDetail
Refinement pathpath: ["fieldName"]
deps optionregister("field", { deps: "otherField" })
Re-validationTriggered 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

PitfallWhy It HappensFix
register rules not firingResolver overrides themPut all rules in schema
Cross-field error not updatingNo deps on the changed fieldAdd deps: "otherField"
Type error with z.coerceInput type is unknownUse useForm<z.input<>, unknown, z.infer<>>
Field array rows not reorderingindex used as keyUse field.id
Browser validation interceptsNo noValidateAdd 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

ItemValue
Packagesreact-hook-form, zod, @hookform/resolvers
Resolver importzodResolver from @hookform/resolvers/zod
useForm optionresolver: zodResolver(Schema)
register rulesIgnored with resolver
Cross-field validationdeps: "otherField"
Field array keyfield.id
Transform typinguseForm<z.input<>, unknown, z.infer<>>
Coercion typingSame three-generic pattern

Key takeaways:

  • The resolver makes the schema the validation source. When zodResolver is present, register‘s built-in rules and useForm‘s form-level validate are ignored. Every rule must be in the schema .
  • Types are inferred when no transform exists. Calling useForm without 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; handleSubmit receives the output type. z.coerce infers the input as unknown, which produces a type error if the generic is set to z.infer alone .
  • Cross-field validation needs deps. The refinement’s error appears at its path, but React Hook Form only re-validates the changed field. The deps option re-validates the other field when the current one changes .
  • Field arrays use field.id, not index. The id is the stable identity that React uses to track rows; the index changes 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.

Was this helpful?


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!