Skip to main content

Command Palette

Search for a command to run...

How to Validate Multi-Step Forms Per Step with React Hook Form and Zod

Published

Validation in a multi-step form is fundamentally different from validation in a single-page form. In a single-page form, all fields are visible and the validation schema applies to the entire field set at the moment of submission. In a multi-step form, validation needs to apply selectively: only to the fields visible in the current step, only at the moment the user attempts to advance, and in a way that does not surface errors for fields the user has not yet seen. Getting this wrong produces one of the most frustrating form experiences possible -- the user on step two sees errors for step-four fields they have not reached yet, or the user completes the entire form and discovers on submission that step one had an invalid entry they corrected three steps ago. This guide covers how to implement per-step validation correctly using React Hook Form and Zod, with notes on applying the same patterns in Formik and Yup.

Notebook with pen resting on desk beside a checklist of tasks with checkmarks Photo by Jakub Zerdzicki on Pexels

The Core Architecture: Step-Scoped Schemas

The foundation of per-step validation is defining separate validation schemas for each step rather than a single unified schema for the entire form. With Zod, each step gets its own z.object() definition containing only the fields present in that step. The parent form component maintains an array of these schemas, indexed to match the step index.

import { z } from "zod";

export const step1Schema = z.object({
  firstName: z.string().min(1, "First name is required"),
  lastName: z.string().min(1, "Last name is required"),
  email: z.string().email("Enter a valid email address"),
});

export const step2Schema = z.object({
  companyName: z.string().min(1, "Company name is required"),
  companySize: z.enum(["1-10", "11-50", "51-200", "200+"], {
    errorMap: () => ({ message: "Select a company size" }),
  }),
  industry: z.string().min(1, "Industry is required"),
});

export const step3Schema = z.object({
  plan: z.enum(["starter", "professional", "enterprise"]),
  billingCycle: z.enum(["monthly", "annual"]),
});

export const stepSchemas = [step1Schema, step2Schema, step3Schema];

Each schema is independent. Zod does not know that these schemas belong to the same form. The orchestration layer -- the parent component -- determines which schema applies at any given moment. This separation makes it straightforward to add, remove, or reorder steps without touching the schemas for other steps. It also makes each schema independently testable, which is a meaningful maintenance benefit for forms that evolve over time.

Connecting the Schema to React Hook Form

React Hook Form integrates with Zod via the @hookform/resolvers package, which provides a zodResolver adapter. The resolver normally receives a single schema for the entire form. For per-step validation, the resolver must receive the currently active schema, which changes as the step changes. This requires a small architectural decision: either reinitialize React Hook Form with a new resolver when the step changes, or use a dynamic schema that delegates validation to the step-appropriate sub-schema.

The more elegant approach is a dynamic resolver that takes the current step as a closure variable:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { stepSchemas } from "./schemas";

function MultiStepForm() {
  const [currentStep, setCurrentStep] = React.useState(0);

  const form = useForm({
    resolver: zodResolver(stepSchemas[currentStep]),
    mode: "onSubmit",
    reValidateMode: "onChange",
  });

  // ...
}

This works cleanly when currentStep is a dependency that causes the resolver to update. React Hook Form reads the resolver on each validation trigger, so passing stepSchemas[currentStep] as the resolver argument ensures the correct schema validates the current fields. The important configuration choice here is mode: "onSubmit". This tells React Hook Form not to validate fields as the user types or on blur -- it validates only when explicitly triggered, which is what you want when validation should run only on step advance.

Triggering Validation on Step Advance

With the resolver set to the current step's schema, triggering validation means calling React Hook Form's trigger function without arguments (to validate all fields) or with a specific list of field names (to validate only those fields). Calling trigger() returns a promise that resolves to true if all fields pass validation and false if any fail.

const handleNextStep = async () => {
  const isValid = await form.trigger();
  if (isValid) {
    setCurrentStep((prev) => prev + 1);
  }
};

Because the resolver is scoped to the current step's schema, calling trigger() validates only the fields in that schema. Fields from other steps are not present in the schema and are not validated. Errors are surfaced only for the fields that belong to the current step. If validation fails, the user stays on the current step and errors appear inline beside the relevant fields.

One subtlety: React Hook Form by default will only run validation for fields that have been registered. Fields from previous steps that are no longer rendered may not be registered when the user is on a later step. This is actually the desired behavior -- you want validation scoped to the current step. Ensure that each step's fields are registered when the step mounts (which happens automatically when the field components render) and unregistered when the step unmounts. If you use React Hook Form's FormProvider and useFormContext, the field registrations from all steps that have ever been rendered persist in the form context, which can cause unexpected cross-step validation. Set shouldUnregister: false explicitly in useForm to preserve values across step unmounts while using explicit trigger calls with field arrays to control which fields are validated.

Per-Step Validation with Formik and Yup

The same pattern applies in Formik with Yup. Define an array of Yup schemas, one per step. Pass the schema for the current step as Formik's validationSchema prop. Formik re-reads validationSchema on each validation trigger, so updating the prop when the step changes correctly scopes validation to the current step.

import { Formik } from "formik";
import * as Yup from "yup";

const stepSchemas = [
  Yup.object({
    firstName: Yup.string().required("First name is required"),
    email: Yup.string().email("Enter a valid email").required("Email is required"),
  }),
  Yup.object({
    companyName: Yup.string().required("Company name is required"),
  }),
];

function MultiStepForm() {
  const [currentStep, setCurrentStep] = React.useState(0);

  return (
    <Formik
      initialValues={{ firstName: "", email: "", companyName: "" }}
      validationSchema={stepSchemas[currentStep]}
      validateOnChange={false}
      validateOnBlur={false}
      onSubmit={handleFinalSubmit}
    >
      {({ validateForm, setTouched, errors }) => (
        // step rendering
      )}
    </Formik>
  );
}

To trigger validation on step advance, call Formik's validateForm() and check the result. Formik's validateForm() runs the current validationSchema against all current values and returns an errors object. An empty errors object means all fields passed. A non-empty errors object means validation failed, and the keys identify which fields need correction.

Handling Async Validation

Some fields require server-side validation that cannot be handled by a synchronous Zod or Yup schema. A username availability check, a license key verification, or an email uniqueness check must reach the server to resolve. Async validation in React Hook Form is handled by the resolver returning a promise, or by using the validate option on individual fields (the register API does not support async directly, but custom controllers via Controller do).

The cleanest approach for async field validation in a multi-step form is to run the async check when the field loses focus on the specific field that requires it, store the result in additional state, and include that state in the "can advance" check alongside the synchronous validation result. This keeps the synchronous schema clean and makes the async check explicit rather than embedded in a schema where the timing and error handling are less transparent.

Error Display Requirements

The MDN form validation guide and the WCAG error identification criteria both specify that form errors must be programmatically associated with the fields they describe. In practice, this means using aria-describedby to link each field to its error message container, and ensuring the error message element has a role or live region configuration that screen readers will announce.

React Hook Form's formState.errors object provides the error messages. The field component reads its error from this object and renders it in an element with an appropriate ID. The input receives aria-describedby pointing to that ID, and aria-invalid="true" when an error is present. The ARIA Authoring Practices Guide has specific guidance on form error patterns that complement the WCAG requirements.

When a step validation failure occurs and errors are displayed, focus should remain on the first field with an error or move to an error summary at the top of the step. Moving focus to the error location is the accessible behavior. A sighted user sees the error markers inline; a keyboard or screen reader user needs focus to move to where the error information is.

Testing Per-Step Validation

Per-step validation has specific test cases that should be verified explicitly. A test suite for a three-step form should confirm: that submitting step one with an empty required field shows an error and does not advance; that submitting step one with valid data advances to step two; that navigating back to step one from step two preserves the valid values; that navigating forward again to step two preserves the values entered there; that final submission is rejected if somehow the complete schema validation fails at the backend level; and that final submission succeeds when all fields are valid.

Testing Library provides the right abstraction level for these tests. Interactions via accessible queries (finding the "Next" button by its text, filling fields by their label text) ensure the tests verify behavior as a real user would experience it. Mocking the async validation fetch calls allows deterministic tests for the async validation paths.

End-to-end tests in Playwright or Cypress should cover the full step progression and verify that the form behaves correctly across a realistic browser interaction, including tab order, keyboard navigation, and error announcement. Jest unit tests are appropriate for the schema definitions themselves: confirm that specific input combinations produce specific validation outcomes from the Zod or Yup schemas before those schemas are connected to any UI.

A persistent integration test that fills all steps, reloads the page, and then verifies state restoration is also valuable if localStorage persistence is implemented. This test catches regressions in the storage write, storage read, and form initialization paths that unit tests for each layer individually would miss. The investment in writing these tests pays for itself quickly in a form that will be maintained and extended over time, because the per-step validation logic is exactly the kind of thing that breaks silently when surrounding code changes.

Tablet showing a multi-step form with progress indicator bar at the top of the screen Photo by Egor Komarov on Pexels

Putting It All Together

Per-step validation is not a complex pattern, but it requires deliberate architecture rather than the default behavior of most form libraries. The key elements are step-scoped schemas rather than a single unified schema, explicit validation triggers on step advance rather than continuous validation, error display scoped to the current step's fields, and accessible error presentation that works for keyboard and screen reader users as well as mouse users.

For the complete implementation -- including the state management architecture, localStorage persistence, back navigation with preserved values, focus management on step transitions, and backend integration -- see How to Build a Multi-Step Form Flow That Saves User Progress. 137Foundry's web development team builds multi-step form flows for onboarding, lead capture, and application processes that require robust validation and high completion rates.