Abdelrahman

React Hook Form + Zod: zodResolver, Conditional Validation, and Multi-Step Forms

Published · Last updated · by Abdelrahman Mostafa

reacttypescriptzodreact-hook-formforms

Multi-step forms are where most React validation setups fall apart. You split a long form across screens, and suddenly the validation logic is scattered across five components, the TypeScript types drift away from the actual rules, and "this field is required only when the user picks business" turns into a pile of useState flags nobody trusts. (You know the pile. You may have written the pile. No judgment — I have the git history to prove I did too.)

This guide fixes that. We build a type-safe multi-step form with Zod and React Hook Form in TypeScript, using a single schema as the source of truth. You get per-step validation, cross-field conditional validation (a field that becomes required based on another field's value), and the same schema reused on the server.

Everything here targets Zod 4 and React Hook Form 7.

Just need React Hook Form and Zod wired together? The quick setup is right below. The multi-step build starts after it.

Quick setup: React Hook Form + Zod with zodResolver

Three packages. @hookform/resolvers is the bridge. There's no separate "zod resolver" package to hunt for; every resolver ships in that one install.

bash
npm install react-hook-form zod @hookform/resolvers

For Zod 4, use @hookform/resolvers v5. Older majors predate Zod 4, and most "react hook form zod 4 doesn't work" threads end with that upgrade.

The smallest useful form:

tsx
// components/signup-form.tsx
"use client";
 
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
 
const signupSchema = z.object({
  email: z.email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
});
 
type SignupValues = z.infer<typeof signupSchema>;
 
export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: zodResolver(signupSchema),
    defaultValues: { email: "", password: "" },
  });
 
  async function onSubmit(values: SignupValues) {
    // only runs once the schema passes
    await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
  }
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input type="email" {...register("email")} />
      {errors.email && <p role="alert">{errors.email.message}</p>}
 
      <input type="password" {...register("password")} />
      {errors.password && <p role="alert">{errors.password.message}</p>}
 
      <button type="submit" disabled={isSubmitting}>
        Sign up
      </button>
    </form>
  );
}

That's the whole integration. Error messages come straight from the schema, so the form component never contains a validation rule.

What zodResolver actually does

React Hook Form doesn't validate anything when you give it a resolver. It hands the current values to the resolver and trusts what comes back. zodResolver(schema) parses those values with your schema and turns each Zod issue into an entry on errors, keyed by the issue's path. An issue at ["address", "city"] becomes errors.address.city. That's why the path option matters so much later on.

It takes two optional arguments after the schema:

ts
zodResolver(schema, schemaOptions, { mode: "async", raw: false });
  • mode: "async" (the default) parses with safeParseAsync, so async refinements just work. "sync" is a hair faster if you have none.
  • raw: true: handleSubmit receives the raw input values instead of Zod's parsed output. You almost never want this. The parsed output is the point.

Also note: once a resolver is set, React Hook Form ignores the built-in rules on register (required, minLength, validate). The schema is the only source of rules. That's the point, but it surprises people mid-migration.

TypeScript: z.infer, z.input, and z.output

z.infer<typeof schema> is all you need until a schema transforms its input. Coercion is the usual culprit: an <input> gives you a string, and z.coerce.number() turns it into a number. Now the form's values and the submitted data are two different types:

ts
const listingSchema = z.object({
  title: z.string().min(1, "Required"),
  price: z.coerce.number().positive("Must be positive"),
});
 
type ListingInput = z.input<typeof listingSchema>; //  price: unknown
type ListingOutput = z.output<typeof listingSchema>; // price: number

Easiest fix: don't pass a generic at all. React Hook Form infers both types from the resolver. If you'd rather be explicit, pass all three:

ts
const form = useForm<ListingInput, unknown, ListingOutput>({
  resolver: zodResolver(listingSchema),
});
 
form.handleSubmit((values) => {
  values.price; // number — already coerced
});

What you can't do is useForm<ListingOutput>() with a transforming schema. TypeScript rejects it with an error like this:

txt
Type 'Resolver<{ title: string; price: unknown; }, any, { title: string; price: number; }>'
is not assignable to type 'Resolver<{ title: string; price: number; }, any, { title: string; price: number; }>'.

If you hit that error after upgrading to Zod 4, this is why.

What we're building

A three-step account checkout:

  • Step 1 — Account: email + password
  • Step 2 — Profile: name + account type (personal or business)
  • Step 3 — Billing: country, and a VAT ID that is required only for business accounts

Two rules make this realistic instead of a toy:

  • Each step must validate on its own before the user moves on.
  • Step 3's VAT ID is conditional — it depends on a choice made back in step 2. Forms, like elephants, never forget.

One schema is the source of truth

The mistake is writing validation per screen. Five screens, five schemas, five slightly different opinions about what a valid email looks like — that's not architecture, that's a support ticket generator. Instead, define one master schema and derive everything else from it — TypeScript types, default values, the resolver React Hook Form uses, and the field list for each step.

ts
// lib/checkout-schema.ts
import { z } from "zod";
 
export const checkoutSchema = z.object({
  // step 1
  email: z.email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
  // step 2
  firstName: z.string().min(1, "Required"),
  lastName: z.string().min(1, "Required"),
  accountType: z.enum(["personal", "business"]),
  // step 3
  country: z.string().min(1, "Select a country"),
  vatId: z.string().optional(),
});
 
export type CheckoutValues = z.infer<typeof checkoutSchema>;

Note the Zod 4 style: z.email() is a top-level validator now, not z.string().email(). z.infer gives you the TypeScript type for free — one change to the schema updates the type everywhere.

Now map each step to the fields it owns. This is the list we'll hand to React Hook Form when validating a single step:

ts
// lib/checkout-schema.ts
export const stepFields = {
  0: ["email", "password"],
  1: ["firstName", "lastName", "accountType"],
  2: ["country", "vatId"],
} satisfies Record<number, (keyof CheckoutValues)[]>;

The satisfies keyword is doing real work here: every string in those arrays is checked against keyof CheckoutValues. Rename vatId in the schema and this map fails to compile until you fix it. That's the whole point — the compiler guards the wiring.

Wire up React Hook Form with the Zod resolver

@hookform/resolvers/zod connects the schema to the form. Because we typed useForm<CheckoutValues>(), register, watch, and errors are all fully typed against the schema.

tsx
// components/checkout-form.tsx
"use client";
 
import { useState } from "react";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  checkoutSchema,
  stepFields,
  type CheckoutValues,
} from "@/lib/checkout-schema";
import { AccountStep } from "./steps/account-step";
import { ProfileStep } from "./steps/profile-step";
import { BillingStep } from "./steps/billing-step";
 
const steps = [AccountStep, ProfileStep, BillingStep];
 
export function CheckoutForm() {
  const [step, setStep] = useState(0);
 
  const methods = useForm<CheckoutValues>({
    resolver: zodResolver(checkoutSchema),
    mode: "onTouched",
    defaultValues: {
      email: "",
      password: "",
      firstName: "",
      lastName: "",
      accountType: "personal",
      country: "",
      vatId: "",
    },
  });
 
  const StepComponent = steps[step];
  const isLastStep = step === steps.length - 1;
 
  async function goNext() {
    // validate ONLY the current step's fields
    const valid = await methods.trigger(stepFields[step], {
      shouldFocus: true,
    });
    if (valid) setStep((s) => Math.min(s + 1, steps.length - 1));
  }
 
  function onSubmit(values: CheckoutValues) {
    // reaching here means every step passed AND the full
    // schema (including conditional rules) re-validated
    console.log("submit", values);
  }
 
  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <StepComponent />
 
        <div>
          {step > 0 && (
            <button type="button" onClick={() => setStep((s) => s - 1)}>
              Back
            </button>
          )}
          {isLastStep ? (
            <button type="submit">Create account</button>
          ) : (
            <button type="button" onClick={goNext}>
              Next
            </button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

Give the default values as an explicit object rather than leaving fields undefined. React Hook Form treats an input as controlled from the first render, and Zod's optional() fields still want a starting value so the component doesn't flip from uncontrolled to controlled.

Validate one step at a time

The key to multi-step validation is trigger. Calling methods.trigger(names) runs the Zod resolver but only reports errors for the fields you pass. That's why we split field lists per step:

ts
const valid = await methods.trigger(stepFields[step], { shouldFocus: true });
if (valid) setStep((s) => s + 1);

Two details worth internalizing:

  • trigger runs the whole schema through the resolver, then React Hook Form filters the result down to the names you passed. So even though the resolver validates step 3's fields too, the user isn't blocked by them on step 1.
  • shouldFocus: true moves the cursor to the first invalid field. Small touch, big difference for accessibility and for anyone filling out a long form.

The FormProvider at the top lets each step component read the form through useFormContext instead of prop-drilling register and errors down every level.

Per-step schemas with .pick(), if you really need them

Sometimes a step needs its own schema. Maybe each step saves a draft to its own endpoint, and that endpoint validates only its own slice. Derive it, don't rewrite it: .pick() the step's fields from the master object.

There's a trap here: pick from the base object, before any .refine(). Zod 4 throws .pick() cannot be used on object schemas containing refinements if you try it on the refined schema. So split the definition in two:

ts
// lib/checkout-schema.ts
export const checkoutBase = z.object({
  /* ...all the fields from above... */
});
 
export const checkoutSchema = checkoutBase.refine(/* cross-field rules */);
 
// per-step slices, still derived from one definition
export const accountStepSchema = checkoutBase.pick({
  email: true,
  password: true,
});

The step slice can't see cross-field rules that reach into other steps. That's correct, because it doesn't have those fields. The full checkoutSchema still runs on final submit.

Conditional validation: a field required by another field's value

This is the part people search for at 11pm and the part naive setups get wrong. VAT ID is optional in the base schema, but it must be required when accountType === "business". Attach the rule to the object with .refine() and point the error at the dependent field using path:

ts
// lib/checkout-schema.ts
export const checkoutSchema = z
  .object({
    email: z.email("Enter a valid email"),
    password: z.string().min(8, "At least 8 characters"),
    firstName: z.string().min(1, "Required"),
    lastName: z.string().min(1, "Required"),
    accountType: z.enum(["personal", "business"]),
    country: z.string().min(1, "Select a country"),
    vatId: z.string().optional(),
  })
  .refine((data) => data.accountType !== "business" || Boolean(data.vatId), {
    message: "VAT ID is required for business accounts",
    path: ["vatId"],
  });

The path: ["vatId"] is what makes this usable. Without it, the error lands on the whole form object and you have nowhere to render it. With it, the error shows up as errors.vatId — exactly where the user needs to see it, right under the input.

There's a subtle interaction with multi-step validation, and it's the one gotcha to remember: because path targets vatId, and vatId is in stepFields[2], the conditional error correctly surfaces when the user clicks Next on step 3. Cross-field rules only fire when the fields they read live in the same schema — one more reason to keep a single master schema instead of one per step.

One more Zod 4 detail for the "my refine never runs" crowd: an object-level refinement is skipped when another field already failed with a type error (a number where a string belongs, for example). Plain rule failures like .min() don't block it. If you need the conditional error regardless, Zod 4's when option decides when the check runs. Run it whenever the two fields it reads are valid:

ts
.refine((data) => data.accountType !== "business" || Boolean(data.vatId), {
  message: "VAT ID is required for business accounts",
  path: ["vatId"],
  when: (payload) =>
    checkoutBase
      .pick({ accountType: true, vatId: true })
      .safeParse(payload.value).success,
});

Multiple conditional rules with superRefine

.refine() is great for one rule. When you have several cross-field checks, .superRefine() lets you add issues imperatively and target a different path for each:

ts
.superRefine((data, ctx) => {
  if (data.accountType === "business" && !data.vatId) {
    ctx.addIssue({
      code: "custom",
      path: ["vatId"],
      message: "VAT ID is required for business accounts",
    });
  }
 
  if (data.country === "US" && !/^\d{9}$/.test(data.vatId ?? "")) {
    ctx.addIssue({
      code: "custom",
      path: ["vatId"],
      message: "US tax IDs must be exactly 9 digits",
    });
  }
});

In Zod 4 the issue code is the string "custom". Each addIssue call attaches its own message to its own path, so you can drive several conditional messages on one or many fields from a single block.

When the shape itself changes: discriminated unions

Sometimes a field doesn't just become required — a whole set of fields swaps in. "Contact by email" needs an email; "contact by SMS" needs a phone number. That's a discriminated union, and Zod validates the correct branch automatically based on the discriminator:

ts
const contactSchema = z.discriminatedUnion("method", [
  z.object({ method: z.literal("email"), email: z.email() }),
  z.object({ method: z.literal("sms"), phone: z.string().min(10) }),
]);

When method is "email", only email is validated; when it's "sms", only phone is. Reach for a discriminated union when the shape changes and a refine when a field's rules change but the shape stays the same.

Async validation: "is this username taken?"

Some rules need a network round trip. Zod handles it with an async refinement:

ts
const usernameSchema = z.object({
  username: z
    .string()
    .min(3, "At least 3 characters")
    .refine(async (username) => {
      const res = await fetch(
        `/api/username-available?u=${encodeURIComponent(username)}`,
      );
      const { available } = await res.json();
      return available;
    }, "That username is taken"),
});

On the client this works out of the box, because zodResolver parses asynchronously by default. Two things will bite you, though.

The server must use safeParseAsync. Calling safeParse on a schema with an async refinement doesn't return an error. It throws Encountered Promise during synchronous parse. Use .parseAsync() instead. The route handler further down would need await schema.safeParseAsync(body).

The resolver runs the whole schema, every time. Even trigger(["email"]) parses every field, so an async refine inside the master schema fires a request whenever any field validates. With mode: "onChange" that's a fetch per keystroke, anywhere in the form. For anything expensive, keep the network check out of the schema. Run it when the user leaves the step and report the result with setError:

ts
async function goNext() {
  const valid = await methods.trigger(stepFields[step], { shouldFocus: true });
  if (!valid) return;
 
  if (step === 0) {
    const taken = await isEmailTaken(methods.getValues("email"));
    if (taken) {
      methods.setError(
        "email",
        { type: "server", message: "That email already has an account" },
        { shouldFocus: true },
      );
      return;
    }
  }
 
  setStep((s) => s + 1);
}

The schema stays synchronous and cheap. The expensive check runs once per step, and the error still lands on errors.email, under the right input.

Render the conditional field in the step

The schema decides validity; the component decides visibility. Read accountType with useWatch and only show the VAT input for business accounts:

tsx
// components/steps/billing-step.tsx
"use client";
 
import { useFormContext, useWatch } from "react-hook-form";
import type { CheckoutValues } from "@/lib/checkout-schema";
 
export function BillingStep() {
  const {
    register,
    formState: { errors },
  } = useFormContext<CheckoutValues>();
 
  const accountType = useWatch<CheckoutValues, "accountType">({
    name: "accountType",
  });
 
  return (
    <fieldset>
      <label>
        Country
        <input {...register("country")} />
      </label>
      {errors.country && <p role="alert">{errors.country.message}</p>}
 
      {accountType === "business" && (
        <label>
          VAT ID
          <input {...register("vatId")} />
          {errors.vatId && <p role="alert">{errors.vatId.message}</p>}
        </label>
      )}
    </fieldset>
  );
}

useFormContext<CheckoutValues>() gives this deeply-nested component the same typed form API as the root, no props required. Keep the visibility check (useWatch) and the validity check (the schema's refine) in agreement — the schema is what actually protects your data; the useWatch is just UX.

React Hook Form and the React Compiler

Why useWatch and not watch("accountType")? Both read the same value, but only one survives the React Compiler. The compiler memoizes components automatically, and watch() is a function whose result changes without React knowing. Memoize around it and your conditional field stops appearing. No error, just a VAT input that never shows up.

eslint-plugin-react-hooks knows this. With the compiler rules on, it flags useForm as an incompatible library: "React Hook Form's useForm() API returns a watch() function which cannot be memoized safely." The fix is mechanical:

  • Reading a value to render with: useWatch({ name }) instead of watch(name). It's a real hook with a real subscription, so the compiler can reason about it.
  • Reading a value inside an event handler: getValues(name). It doesn't need to re-render anything.

Everything else in this guide (register, trigger, handleSubmit, useFormContext) works with the compiler as-is.

Reuse the exact same schema on the server

The biggest payoff of a single schema: your server validates with the identical rules. Never trust the client — the client is a stranger's computer running code it may have edited in DevTools for fun. But "don't trust" doesn't mean "rewrite the rules twice." Import the schema into a route handler or server action:

ts
// app/api/checkout/route.ts
import { z } from "zod";
import { checkoutSchema } from "@/lib/checkout-schema";
 
export async function POST(request: Request) {
  const body = await request.json();
  const result = checkoutSchema.safeParse(body);
 
  if (!result.success) {
    return Response.json(
      { errors: z.flattenError(result.error) },
      { status: 400 },
    );
  }
 
  // result.data is fully typed as CheckoutValues,
  // conditional rules already enforced
  return Response.json({ ok: true });
}

z.flattenError() (the Zod 4 replacement for error.flatten()) returns { formErrors, fieldErrors } — a shape that's trivial to map back onto form fields if you want to surface server errors in the UI. Client and server can never disagree, because they're reading the same file.

Gotchas worth remembering

  • Conditional rules need shared fields. A refine can only compare fields that live in the same schema. This is the real reason to avoid one-schema-per-step.
  • Always set path. Object-level refine/superRefine errors go to the form root unless you point them at a field with path. No path means no place to render it.
  • trigger validates the full schema, reports a subset. That's a feature — later steps don't block earlier ones — but it means a broken schema elsewhere can still fail a submit that looked fine step by step.
  • Give every field a default value. Prevents the controlled/uncontrolled warning and keeps optional() fields happy.
  • Use mode: "onTouched" or "onBlur" for long forms. Validating onChange on every keystroke across many fields is noisy and slow.
  • Async refinements need safeParseAsync on the server, and they re-run whenever any field validates. Expensive checks belong in the step handler with setError.
  • .pick() before .refine(). Zod 4 refuses to pick from a refined object.
  • Under the React Compiler, prefer useWatch to watch.

Wrapping up

One Zod schema drove the whole thing: TypeScript types, per-step validation via trigger, conditional rules with refine/superRefine and path, async checks that don't hammer your API, shape-swapping with discriminated unions, and identical server-side checks. That's the payoff of treating the schema as the single source of truth — the form's rules live in exactly one place, and everything else is derived from it.

And the useState flag pile from the intro? Deleted. Nobody misses it. Not even the flags.

FAQ

What is zodResolver?

zodResolver is the adapter from @hookform/resolvers that lets React Hook Form validate with a Zod schema. Pass it to useForm({ resolver: zodResolver(schema) }). On every validation it parses the form values with the schema and maps each Zod issue onto React Hook Form's errors object by its path, so errors.email.message holds the message from your schema.

How do I install @hookform/resolvers/zod?

Run npm install react-hook-form zod @hookform/resolvers. There's no separate Zod resolver package. @hookform/resolvers ships every resolver, and you import the Zod one with import { zodResolver } from "@hookform/resolvers/zod". For Zod 4, use @hookform/resolvers v5.

Zod vs React Hook Form: what's the difference?

They solve different halves of the problem. Zod is a schema library: it describes what valid data looks like and infers the TypeScript type from it, on the client or the server. React Hook Form manages form state: registering inputs, tracking touched and dirty fields, focus, and submission. zodResolver connects the two, so React Hook Form asks Zod whether the values are valid.

How do I do async validation with Zod and React Hook Form?

Add an async .refine() to the field, for example a fetch that checks whether a username is taken. zodResolver parses asynchronously by default, so it works without extra config. On the server use safeParseAsync, because safeParse throws on a schema with an async refinement. For expensive checks, run them in your submit or next-step handler and report failures with setError instead, because the resolver re-runs the whole schema on every validation.

How do I validate only the current step in a multi-step form?

Call React Hook Form's trigger() with just that step's field names, for example await trigger(["email", "password"]). It runs the Zod resolver but only surfaces errors for the fields you pass, so the user can advance without the later steps blocking them.

How do I make a Zod field required only when another field has a specific value?

Use .refine() or .superRefine() on the object schema and target the dependent field with the path option: .refine((d) => d.accountType !== "business" || Boolean(d.vatId), { path: ["vatId"], message: "VAT ID is required" }). The error attaches to vatId so it renders on the right input.

Should I use one Zod schema or one per step?

Define one master schema and derive per-step field lists from it. If you need real per-step schemas, call .pick() on the base object before adding .refine(), because Zod 4 throws if you pick from a refined object. A single source of truth keeps your TypeScript types, default values, resolver, and server validation in sync. Cross-field rules only work when the fields live in the same schema.

Can I reuse the same Zod schema on the server?

Yes. Import the schema into a route handler or server action and call schema.safeParse(body). You get the same validation and the same inferred TypeScript type, so the client and server can never drift apart.