React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

react-hook-formzodresolverhookformtyped-forms

RHF + Zod

Integrate Zod schemas with react-hook-form via @hookform/resolvers/zod for fully typed, schema-driven forms.

Recipe

Quick-reference recipe card — copy-paste ready.

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
const Schema = z.object({
  email: z.string().email("Invalid email"),
  password: z.string().min(8, "At least 8 characters"),
});
 
type FormData = z.infer<typeof Schema>;
 
function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>({
    resolver: zodResolver(Schema),
    defaultValues: { email: "", password: "" },
  });
 
  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
 
      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}
 
      <button type="submit">Log in</button>
    </form>
  );
}

When to reach for this: When you want schema-defined validation rules (Zod) powering react-hook-form's performant field-level error display.

Working Example

"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
const ProfileSchema = z
  .object({
    username: z
      .string()
      .min(3, "At least 3 characters")
      .max(20)
      .regex(/^[a-z0-9_]+$/, "Lowercase letters, numbers, underscores only"),
    displayName: z.string().min(1, "Required"),
    bio: z.string().max(500).optional(),
    website: z.string().url("Invalid URL").optional().or(z.literal("")),
    newPassword: z.string().min(8).optional().or(z.literal("")),
    confirmPassword: z.string().optional().or(z.literal("")),
  })
  .refine(
    (data) => {
      if (data.newPassword && data.newPassword !== data.confirmPassword) return false;
      return true;
    },
    { message: "Passwords must match", path: ["confirmPassword"] }
  );
 
type ProfileFormData = z.infer<typeof ProfileSchema>;
 
export function ProfileEditor() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty },
    reset,
  } = useForm<ProfileFormData>({
    resolver: zodResolver(ProfileSchema),
    defaultValues: {
      username: "johndoe",
      displayName: "John Doe",
      bio: "",
      website: "",
      newPassword: "",
      confirmPassword: "",
    },
    mode: "onBlur",
  });
 
  async function onSubmit(data: ProfileFormData) {
    await new Promise((r) => setTimeout(r, 1000)); // simulate API
    console.log("Saved:", data);
    reset(data);
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
      <div>
        <label className="block text-sm font-medium">Username</label>
        <input {...register("username")} className="w-full rounded border p-2" />
        {errors.username && <p className="text-sm text-red-600">{errors.username.message}</p>}
      </div>
 
      <div>
        <label className="block text-sm font-medium">Display Name</label>
        <input {...register("displayName")} className="w-full rounded border p-2" />
        {errors.displayName && <p className="text-sm text-red-600">{errors.displayName.message}</p>}
      </div>
 
      <div>
        <label className="block text-sm font-medium">Bio</label>
        <textarea {...register("bio")} rows={3} className="w-full rounded border p-2" />
        {errors.bio && <p className="text-sm text-red-600">{errors.bio.message}</p>}
      </div>
 
      <div>
        <label className="block text-sm font-medium">Website</label>
        <input {...register("website")} placeholder="https://..." className="w-full rounded border p-2" />
        {errors.website && <p className="text-sm text-red-600">{errors.website.message}</p>}
      </div>
 
      <fieldset className="rounded border p-3">
        <legend className="px-1 text-sm font-medium">Change Password (optional)</legend>
        <div className="space-y-2">
          <input {...register("newPassword")} type="password" placeholder="New password" className="w-full rounded border p-2" />
          {errors.newPassword && <p className="text-sm text-red-600">{errors.newPassword.message}</p>}
          <input {...register("confirmPassword")} type="password" placeholder="Confirm" className="w-full rounded border p-2" />
          {errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
        </div>
      </fieldset>
 
      <button
        type="submit"
        disabled={isSubmitting || !isDirty}
        className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isSubmitting ? "Saving..." : "Save Profile"}
      </button>
    </form>
  );
}

What this demonstrates:

  • zodResolver wiring to useForm
  • Object-level .refine() for cross-field validation with path targeting
  • Optional fields that accept empty strings (.or(z.literal("")))
  • mode: "onBlur" for validate-on-blur behavior
  • Dirty tracking and submit-state management

Deep Dive

How It Works

  • zodResolver(schema) returns a function matching RHF's Resolver type
  • On form submission (or mode-triggered validation), RHF calls the resolver with all field values
  • The resolver runs schema.safeParse(values) and maps ZodError.issues to RHF's FieldErrors format
  • RHF then distributes errors to the correct fields via errors.fieldName
  • Schema-level .refine() errors map to the path you specify, or to root if no path is given

Variations

Accessing root-level errors:

const { formState: { errors } } = useForm({ resolver: zodResolver(schema) });
 
// Root error from a .refine() without a path
errors.root?.message;

Resolver with custom error mapping:

useForm({
  resolver: zodResolver(Schema, {
    // Pass Zod options
    errorMap: (issue, ctx) => ({
      message: customMessages[issue.code] ?? ctx.defaultError,
    }),
  }),
});

Schema with async validation:

const Schema = z.object({
  username: z.string().refine(async (val) => {
    const available = await checkUsername(val);
    return available;
  }, "Username taken"),
});
 
// zodResolver handles async automatically
useForm({ resolver: zodResolver(Schema) });

TypeScript Notes

// The generic type flows from the schema
const Schema = z.object({ name: z.string() });
type T = z.infer<typeof Schema>;
 
// useForm is typed by the generic, not the resolver
const form = useForm<T>({ resolver: zodResolver(Schema) });
 
// If types mismatch between z.infer and the generic, TS catches it
const BadSchema = z.object({ email: z.string() });
// useForm<T>({ resolver: zodResolver(BadSchema) }) — no TS error at resolver level
// but register("name") will still be type-safe against T
 
// Best practice: derive the type from the schema
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });

Gotchas

  • Schema and generic type must stay in sync — If you change the schema but not the useForm generic (or vice versa), validation and types diverge silently. Fix: Always derive the form type with z.infer<typeof Schema>.

  • Optional fields with empty strings — HTML inputs submit "" for empty fields, but z.string().optional() expects undefined. Fix: Use .optional().or(z.literal("")) or preprocess empty strings to undefined.

  • Transforms not reflected in form valueszodResolver returns the parsed (transformed) data to onSubmit, but the form fields still show the raw input values. Fix: This is expected; transforms apply only to the data passed to onSubmit.

  • Cross-field errors need path — Object-level .refine() without path puts the error on errors.root, which is easy to miss in the UI. Fix: Always specify path: ["fieldName"].

Alternatives

AlternativeUse WhenDon't Use When
Yup resolverLegacy codebase using Yup schemasStarting fresh (Zod has better inference)
Valibot resolverYou need minimal bundle sizeYou need Zod's ecosystem
Built-in RHF validationVery simple rules (required, minLength) onlyYou want centralized schema validation
Server-only validationForms submit via server actions with no client JSYou need instant field-level feedback

FAQs

What package connects Zod schemas to react-hook-form?

The @hookform/resolvers package provides zodResolver, which you pass to useForm({ resolver: zodResolver(Schema) }).

How do you derive the form's TypeScript type from a Zod schema?
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });
What does zodResolver do internally when the form is submitted?
  • It calls schema.safeParse(values) with all field values
  • On failure, it maps ZodError.issues to RHF's FieldErrors format
  • RHF then distributes errors to the correct fields via errors.fieldName
How do you handle optional fields that HTML inputs submit as empty strings?

Use .optional().or(z.literal("")) on the schema field. Without this, z.string().optional() expects undefined, but HTML inputs send "".

How do you validate that two fields match (e.g., password confirmation)?
const Schema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
  message: "Passwords must match",
  path: ["confirmPassword"],
});
What does mode: "onBlur" do in useForm?

It triggers validation when a field loses focus instead of only on submit, giving users feedback as they move between fields.

Where does a .refine() error appear if you omit the path option?

It lands on errors.root, which is easy to miss in the UI. Always specify path: ["fieldName"] to attach the error to the correct field.

Gotcha: What happens if your Zod schema and useForm generic type get out of sync?

Validation and types diverge silently. The resolver validates against the schema shape, but register() is type-checked against the generic. Always derive the type with z.infer<typeof Schema> to keep them in sync.

Gotcha: Does a Zod .transform() change the values displayed in form fields?

No. zodResolver returns the transformed data only to the onSubmit handler. The form fields still display the raw input values. This is expected behavior.

How do you use async validation (e.g., checking username availability) with zodResolver?
const Schema = z.object({
  username: z.string().refine(async (val) => {
    return await checkUsername(val);
  }, "Username taken"),
});
// zodResolver handles async automatically
useForm({ resolver: zodResolver(Schema) });
How do you pass a custom error map to zodResolver?
useForm({
  resolver: zodResolver(Schema, {
    errorMap: (issue, ctx) => ({
      message: customMessages[issue.code] ?? ctx.defaultError,
    }),
  }),
});
TypeScript: Why should you avoid manually writing an interface for form data?
  • Manual interfaces can drift from the schema, causing runtime validation to disagree with compile-time types
  • z.infer<typeof Schema> is always in sync with the schema definition
  • It eliminates an entire class of bugs where a field is added to one but not the other
TypeScript: Does the zodResolver itself catch type mismatches between the schema and the useForm generic?

No. TypeScript does not flag a mismatch at the resolver level. The type safety comes from register("fieldName") being checked against the generic. This is why deriving the type from the schema is critical.