# React Multi Step Form with Zod, Saved Drafts and Motion

> Build a React multi step form with per-step zod validation, an async check, progress saved across reloads and slides that follow direction, final code included.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 13, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/react-multi-step-form

## TL;DR

Keep one react-hook-form instance for the whole React multi step form, give each step its own zod object, and on Continue call trigger() with only that step's field names. Run async checks such as a username lookup after the sync rules pass, save the values and the current step to localStorage on every change, and slide the next step in from the side the user is heading. The Wingo UI Form Wizard does the step logic, focus and transitions; the final code below adds the zod schemas, the async check and the saved draft.

A React multi step form looks like a list of forms with a Next button between them. The work hides in the gaps: Continue has to validate only the fields on screen, a server check needs a spinner and an error in the right place, a reload should not throw away three steps of typing, and the next step should slide in from the side the user is heading. This tutorial builds a four step workspace signup that handles all of it with react-hook-form, zod and the [Form Wizard](https://wingo-ui.com/components/form-wizard) from Wingo UI, the library we build. Stepper visuals get their own guide in [the React stepper component post](https://wingo-ui.com/blog/react-stepper-component).

## What does a good React multi step form need?

It needs seven things: per-step validation, async checks that run after the sync ones, focus that follows the user, visible progress, saved progress, direction-aware motion and one final submit. Here is what each means in practice:

- **Per-step validation.** Continue checks the current step only. Back never validates, because nobody should have to fix a field before they can go back and look at something.
- **Async checks after sync ones.** A "Is this URL taken?" request runs only when the format is already right, and the button shows that something is happening.
- **Focus that follows the user.** A failed step focuses the first invalid field. A new step moves focus to its heading, and a screen reader hears "Step 2 of 4: Workspace".
- **Progress you can see.** A stepper on wide screens, a progress bar with "Step 2 of 4" on phones.
- **Saved progress.** Values and the current step survive a reload or a closed tab.
- **Direction-aware motion.** Forward slides left, Back slides right, and reduced motion turns both into a crossfade.
- **One submit.** The last step sends a single object that the server validates again.

The Form Wizard covers the step state, focus, the stepper and the transitions. You bring the fields, the schemas and the draft storage. Install it with the fields this tutorial uses:

```bash
npx wingo-ui@latest add form-wizard input radio-group checkbox form-validation
```

The CLI also installs `react-hook-form`, `@hookform/resolvers` and `zod`, which the validation helpers depend on.

> Live demo (Form Wizard): Press Continue on an empty step: the marker turns into a red "!", the button shakes and focus jumps to the first invalid field. Fill it in and go forward, then press Back and watch the slide reverse. Try it at [Form Wizard](https://wingo-ui.com/components/form-wizard) and install it with `npx wingo-ui@latest add form-wizard`.

## How do I split a zod schema into steps?

Write one zod object per step and merge them into the schema for the whole form. Each step's field names then come straight from its own schema, so a field added to a step is validated by that step without a second list to keep in sync. This is the core of a zod multi step form:

```tsx
import { z } from "zod";
import { createValidators } from "@/lib/form-validation";

const v = createValidators();

const accountSchema = z.object({
  name: v.name(),
  email: v.email(),
});

const workspaceSchema = z.object({
  workspace: v.text({ min: 3, max: 40 }),
  slug: z
    .string()
    .trim()
    .regex(/^[a-z0-9-]{3,30}$/, { error: "Use 3 to 30 lowercase letters, digits or dashes" }),
});

const teamSchema = z.object({
  teamSize: z.string().min(1, { error: "Pick a team size" }),
});

const reviewSchema = z.object({
  terms: v.accept(),
});

const signupSchema = z.object({
  ...accountSchema.shape,
  ...workspaceSchema.shape,
  ...teamSchema.shape,
  ...reviewSchema.shape,
});

type Signup = z.input<typeof signupSchema>;

const fieldsOf = (schema: z.ZodObject) => Object.keys(schema.shape) as (keyof Signup)[];
```

`createValidators` comes from the [form validation helpers](https://wingo-ui.com/components/form-validation): zod rules for names, emails, passwords, phone numbers and a ticked checkbox that share one set of English messages. Plain zod works the same way if you prefer your own rules. The examples use zod 4, where the message option is `error`.

## How do I validate one step at a time with react-hook-form?

Use one `useForm` for the whole form and call `trigger()` with the current step's fields when the user presses Continue. The [react-hook-form docs for trigger](https://react-hook-form.com/docs/useform/trigger) describe it as returning a promise that resolves `true` when the targeted fields pass. With the zod resolver the full schema still runs, but only the fields you name get errors, so step 1 never shows messages for step 3.

A single form instance is also what makes this a react hook form wizard instead of four forms. The library's own [wizard example](https://react-hook-form.com/advanced-usage#WizardFormFunnel) uses a separate store between pages, which fits a multi-page flow with routes. Inside one page you do not need it: per the [useForm docs](https://react-hook-form.com/docs/useform#shouldUnregister), `shouldUnregister` defaults to `false`, so a field keeps its value after its step unmounts.

Each step registers its validator with `useFormWizardValidation`. The wizard runs it on Continue, on Enter in a single-line field and before a forward jump in the stepper:

```tsx
function AccountStep() {
  const { register, trigger, formState } = useFormContext<Signup>();
  useFormWizardValidation(() => trigger(fieldsOf(accountSchema)));
  return (
    <div className="flex flex-col gap-4">
      <Input label="Full name" autoComplete="name" error={formState.errors.name?.message} {...register("name")} />
      <Input label="Work email" type="email" autoComplete="email" error={formState.errors.email?.message} {...register("email")} />
    </div>
  );
}
```

When the validator resolves `false`, the wizard marks the step with an error, shakes Continue and focuses the first element with `aria-invalid="true"` in the step. Wingo UI fields set that attribute whenever they get an `error`, through the shared [Field](https://wingo-ui.com/components/field) anatomy, so focus lands on the right field without a ref per input. Controls that are not plain inputs, like a radio group, go through `Controller` and pass `fieldState.error?.message` to `error`.

`useZodForm` from the same helper file is `useForm` with the zod resolver and `mode: "onTouched"`: an error appears after the user leaves a field, then updates on every keystroke until it is fixed.

## How do I add an async check to one step?

Run the cheap rules first, then the request, and put the answer on the field with `setError`. The validator returns a promise, so the wizard keeps the user on the step until it settles:

```tsx
useFormWizardValidation(async () => {
  if (!(await trigger(fieldsOf(workspaceSchema)))) return false;
  try {
    if (await isSlugFree(getValues("slug"))) return true;
    setError("slug", { message: "That URL is taken. Try another one." });
  } catch {
    setError("slug", { message: "We could not check this URL. Try again." });
  }
  return false;
});
```

Three details matter here. The format check stops a typo from reaching the network. The `catch` turns a failed request into a message instead of a silent block. And the wizard only shows a spinner on Continue once a validator has run for 120ms, so the instant `trigger()` calls never flash one. The manual error stays until the field validates again. With `mode: "onTouched"` that happens on each keystroke only after the field has lost focus once, so someone who typed the URL and pressed Enter keeps seeing "taken" while they edit, until they tab away or press Continue again. To clear it as soon as they type, register the field with `register("slug", { onChange: () => clearErrors("slug") })`.

The check here is a convenience. Two people can claim the same URL between the check and the submit, so the server must enforce uniqueness and the final submit must handle a conflict.

## How do I save progress across reloads?

Write the values and the current step to `localStorage` on every change, and read them back after the component mounts. Reading during render would make the server HTML differ from the client's and break hydration, so the wrapper renders a placeholder of the same height until the draft is known:

```tsx
export function SignupWizard() {
  const [draft, setDraft] = useState<Draft | null>();
  useEffect(() => setDraft(readDraft()), []);
  if (draft === undefined) return <div className="min-h-[480px]" />;
  return <SignupForm draft={draft} />;
}
```

Inside the form, `watch(callback)` subscribes to changes without re-rendering on each one. The current step is controlled through `value` and `onValueChange`, so it can be saved with the values. When the wizard mounts on a later step, it counts the steps before it as completed, and the stepper shows them with a check.

Keep secrets out of the draft. A password, a card number or an ID number should never land in `localStorage`, where any script on the page can read it. If a step collects one, leave that field out of what you save and ask for it again.

## How do direction-aware transitions work?

The next step enters from the side you are heading: forward slides in from the right, Back from the left. The Form Wizard compares the new step's index with the old one, slides 32px in that direction, and lets the card's height follow on a spring so the footer does not jump. That height change is a motion layout animation: it measures once and animates a transform, so no layout runs per frame. With `prefers-reduced-motion` it crossfades instead, and `animate={false}` swaps steps with no motion at all.

If you build your own, the one trick is to pass the direction to `AnimatePresence` as well as to the step. The step that is leaving has already left your tree, so it only receives the new direction through `AnimatePresence`'s `custom` prop:

```tsx
const slide: Variants = {
  enter: (direction: number) => ({ x: direction * 32, opacity: 0 }),
  center: { x: 0, opacity: 1 },
  exit: (direction: number) => ({ x: direction * -32, opacity: 0 }),
};

<AnimatePresence mode="popLayout" initial={false} custom={direction}>
  <motion.div key={index} custom={direction} variants={slide} initial="enter" animate="center" exit="exit">
    {steps[index]}
  </motion.div>
</AnimatePresence>;
```

Animate `x` and `opacity` only. A step change that animates `height` or `width` directly runs layout on every frame, which is what makes cheap wizards stutter on mid-range phones. More presets live in [our spring presets post](https://wingo-ui.com/blog/framer-motion-spring-presets).

## What about a progress bar on phones?

Use one. Below 768px a row of five numbered circles does not fit with readable titles, so on phones build the React multi step form with progress bar and a step count instead. The Form Wizard switches on its own: under 768px every stepper becomes a compact header with "Step 2 of 4", the step title and a bar. On wider screens you choose with `stepper`: `"horizontal"` (numbered markers), `"vertical"`, `"progress"`, `"dots"` or `"none"`.

With `fullScreenOnMobile`, the default, the wizard fills its container on a phone: a sticky header, a body that scrolls on its own, and a sticky footer with 48px buttons padded for the home indicator. Give the container a height, such as `h-dvh md:h-auto` on the wizard itself. For a stepper outside a form, such as a checkout that spans several routes, use the standalone [Steps](https://wingo-ui.com/components/steps) component, covered in [the stepper guide](https://wingo-ui.com/blog/react-stepper-component).

## What is the final code?

The whole signup, about 270 lines, with the four steps, the async URL check, the summary with Edit links and the saved draft:

```tsx
"use client";

import { useEffect, useState } from "react";
import { Controller, FormProvider, useFormContext, useWatch } from "react-hook-form";
import { z } from "zod";
import { FormWizard, FormWizardSummary, useFormWizardValidation, type WizardStep } from "@/components/ui/form-wizard";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { RadioGroup, type RadioOption } from "@/components/ui/radio-group";
import { createValidators, useZodForm } from "@/lib/form-validation";

const v = createValidators();

// one zod object per step
const accountSchema = z.object({
  name: v.name(),
  email: v.email(),
});

const workspaceSchema = z.object({
  workspace: v.text({ min: 3, max: 40 }),
  slug: z
    .string()
    .trim()
    .regex(/^[a-z0-9-]{3,30}$/, { error: "Use 3 to 30 lowercase letters, digits or dashes" }),
});

const teamSchema = z.object({
  teamSize: z.string().min(1, { error: "Pick a team size" }),
});

const reviewSchema = z.object({
  terms: v.accept(),
});

// the whole form is the steps merged
const signupSchema = z.object({
  ...accountSchema.shape,
  ...workspaceSchema.shape,
  ...teamSchema.shape,
  ...reviewSchema.shape,
});

type Signup = z.input<typeof signupSchema>;

const EMPTY: Signup = { name: "", email: "", workspace: "", slug: "", teamSize: "", terms: false };

// the field names a step owns, read from its schema
const fieldsOf = (schema: z.ZodObject) => Object.keys(schema.shape) as (keyof Signup)[];

const TEAM_SIZES: RadioOption[] = [
  { value: "solo", label: "Just me" },
  { value: "small", label: "2 to 10 people" },
  { value: "large", label: "More than 10" },
];

function AccountStep() {
  const { register, trigger, formState } = useFormContext<Signup>();
  useFormWizardValidation(() => trigger(fieldsOf(accountSchema)));
  return (
    <div className="flex flex-col gap-4">
      <Input
        label="Full name"
        autoComplete="name"
        enterKeyHint="next"
        error={formState.errors.name?.message}
        {...register("name")}
      />
      <Input
        label="Work email"
        type="email"
        autoComplete="email"
        enterKeyHint="next"
        error={formState.errors.email?.message}
        {...register("email")}
      />
    </div>
  );
}

async function isSlugFree(slug: string) {
  const response = await fetch(`/api/workspaces/available?slug=${encodeURIComponent(slug)}`);
  if (!response.ok) throw new Error(`Availability check failed: ${response.status}`);
  const data = (await response.json()) as { available: boolean };
  return data.available;
}

function WorkspaceStep() {
  const { register, trigger, getValues, setError, formState } = useFormContext<Signup>();
  useFormWizardValidation(async () => {
    // the cheap checks first, so a typo never reaches the network
    if (!(await trigger(fieldsOf(workspaceSchema)))) return false;
    try {
      if (await isSlugFree(getValues("slug"))) return true;
      setError("slug", { message: "That URL is taken. Try another one." });
    } catch {
      setError("slug", { message: "We could not check this URL. Try again." });
    }
    return false;
  });
  return (
    <div className="flex flex-col gap-4">
      <Input
        label="Workspace name"
        placeholder="Carter Studio"
        error={formState.errors.workspace?.message}
        {...register("workspace")}
      />
      <Input
        label="Workspace URL"
        prefix="acme.app/"
        placeholder="carter-studio"
        autoCapitalize="none"
        spellCheck={false}
        error={formState.errors.slug?.message}
        {...register("slug")}
      />
    </div>
  );
}

function TeamStep() {
  const { control, trigger } = useFormContext<Signup>();
  useFormWizardValidation(() => trigger(fieldsOf(teamSchema)));
  return (
    <Controller
      name="teamSize"
      control={control}
      render={({ field, fieldState }) => (
        <RadioGroup
          label="How many people will use it?"
          variant="card"
          options={TEAM_SIZES}
          value={field.value}
          onValueChange={field.onChange}
          onBlur={field.onBlur}
          ref={field.ref}
          name={field.name}
          error={fieldState.error?.message}
        />
      )}
    />
  );
}

function ReviewStep() {
  const { control, trigger } = useFormContext<Signup>();
  useFormWizardValidation(() => trigger(fieldsOf(reviewSchema)));
  const values = useWatch({ control });
  const team = TEAM_SIZES.find((option) => option.value === values.teamSize)?.label;
  return (
    <div className="flex flex-col gap-5">
      <FormWizardSummary
        sections={[
          {
            step: "account",
            items: [
              { label: "Name", value: values.name },
              { label: "Email", value: values.email },
            ],
          },
          {
            step: "workspace",
            items: [
              { label: "Workspace", value: values.workspace },
              { label: "URL", value: `acme.app/${values.slug}` },
            ],
          },
          { step: "team", items: [{ label: "Team size", value: team }] },
        ]}
      />
      <Controller
        name="terms"
        control={control}
        render={({ field, fieldState }) => (
          <Checkbox
            label="I agree to the terms of service"
            checked={field.value}
            onCheckedChange={(checked) => field.onChange(checked === true)}
            onBlur={field.onBlur}
            ref={field.ref}
            error={fieldState.error?.message}
          />
        )}
      />
    </div>
  );
}

const STEPS: WizardStep[] = [
  { id: "account", title: "Account", description: "Who is setting this up?", content: <AccountStep /> },
  { id: "workspace", title: "Workspace", description: "Pick a name and a URL.", content: <WorkspaceStep /> },
  { id: "team", title: "Team", description: "We size the defaults for you.", content: <TeamStep /> },
  { id: "review", title: "Review", description: "Check everything, then create it.", content: <ReviewStep /> },
];

// ---------- saved progress ----------

const DRAFT_KEY = "signup-draft";
type Draft = { step: string; values: Partial<Signup> };

function readDraft(): Draft | null {
  try {
    const raw = localStorage.getItem(DRAFT_KEY);
    return raw ? (JSON.parse(raw) as Draft) : null;
  } catch {
    return null;
  }
}

function writeDraft(draft: Draft) {
  try {
    localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
  } catch {
    // private mode or a full disk: the form still works, it just forgets
  }
}

function clearDraft() {
  try {
    localStorage.removeItem(DRAFT_KEY);
  } catch {}
}

async function createWorkspace(values: z.output<typeof signupSchema>) {
  const response = await fetch("/api/workspaces", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(values),
  });
  if (!response.ok) throw new Error(`Signup failed: ${response.status}`);
}

function SignupForm({ draft }: { draft: Draft | null }) {
  const form = useZodForm(signupSchema, { defaultValues: { ...EMPTY, ...draft?.values } });
  const [step, setStep] = useState(draft?.step ?? "account");
  const [created, setCreated] = useState(false);

  // save the step and every change; the step is part of the draft
  useEffect(() => {
    writeDraft({ step, values: form.getValues() });
    const subscription = form.watch((values) => writeDraft({ step, values: values as Partial<Signup> }));
    return () => subscription.unsubscribe();
  }, [form, step]);

  if (created) return <p role="status">Your workspace is ready.</p>;

  return (
    <FormProvider {...form}>
      <FormWizard
        steps={STEPS}
        value={step}
        onValueChange={setStep}
        labels={{ submit: "Create workspace", done: "Created" }}
        onComplete={async () => {
          // throws on a stale or edited draft; the wizard shakes the button
          const values = signupSchema.parse(form.getValues());
          await createWorkspace(values);
          clearDraft();
          setCreated(true);
        }}
        className="h-dvh md:h-auto"
      />
    </FormProvider>
  );
}

export function SignupWizard() {
  // undefined until the browser has read the draft, so server and client HTML match
  const [draft, setDraft] = useState<Draft | null>();
  useEffect(() => setDraft(readDraft()), []);
  if (draft === undefined) return <div className="min-h-[480px]" />;
  return <SignupForm draft={draft} />;
}
```

Two endpoints are yours to write: `GET /api/workspaces/available` and `POST /api/workspaces`. Parse the body with the same `signupSchema` on the server, so the client rules and the server rules cannot drift apart. When `onComplete` rejects, the wizard shakes the submit button and stays on the review step, and the draft is still saved.

The sign-up rules from `form-validation` also run on their own, outside a wizard. Leave a field, fix it, then submit with everything empty to see the timing `useZodForm` uses:

> Live demo (Form validation): Leave the email field with a typo: the error appears on blur and clears as you fix it. Submit empty and focus jumps to the first error. Try it at [Form validation](https://wingo-ui.com/components/form-validation) and install it with `npx wingo-ui@latest add form-validation`.

## Can I build a shadcn multi step form instead?

Yes. For a survey or an intake flow with one question per screen, use shadcn/ui's [Questionnaire](https://ui.shadcn.com/docs/components/questionnaire), added in [August 2026](https://ui.shadcn.com/docs/changelog/2026-08-questionnaire). It handles the active item, progress, Previous and Next, required answers and focus, and reads the answers from `FormData`. Its docs leave persistence and branching to your page and do not cover react-hook-form.

For steps that each hold several fields, like the signup here, the [shadcn/ui component list](https://ui.shadcn.com/docs/components) has Field, Progress and Button but no stepper or wizard as of October 2026. That shadcn multi step form keeps the step index in state, renders one group of fields per step, calls `trigger()` on Next exactly as above, and adds its own focus handling, announcements and transitions. Community registries publish stepper components that install with the shadcn CLI, such as [ReUI's stepper](https://reui.io/docs/stepper), if you want the visuals without writing them.

The schema split, the `trigger()` per step, the async check and the draft code in this post work the same in that setup, since none of it depends on the wizard component.

## Where should you start?

Start from the [Form Wizard docs](https://wingo-ui.com/components/form-wizard), which have the playground and examples for a vertical stepper, dots for onboarding, an async check and a custom footer with Save draft. The Form Wizard and the form validation helpers are part of Wingo UI Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 ([pricing](https://wingo-ui.com/pricing)); Input, Radio Group, Checkbox and Field are free. For every other field in the form, read the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide), and browse the rest of the [component guides](https://wingo-ui.com/blog/category/components). For a checkout built on the same one-step-at-a-time validation, see the [React checkout page tutorial](https://wingo-ui.com/blog/react-checkout-page).

## Components in this post

- [Form Wizard](https://wingo-ui.com/components/form-wizard) (Pro): A React multi-step form wizard with a stepper, sync or async validation per step, a summary step and a full-screen phone layout. Install: `npx wingo-ui@latest add form-wizard`
- [Steps](https://wingo-ui.com/components/steps) (Pro): A React stepper for checkouts, onboarding and KYC: circles, bars or dots, with complete, error and loading states and a phone header. Install: `npx wingo-ui@latest add steps`
- [Field](https://wingo-ui.com/components/field) (Free): A React form field wrapper every control shares: label, hint, animated error and counter, fieldsets, and the box recipe all inputs use. Install: `npx wingo-ui@latest add field`
- [Form validation](https://wingo-ui.com/components/form-validation) (Pro): Zod validation rules for React forms with one message set (English or Romanian), react-hook-form glue and a pretend request. Install: `npx wingo-ui@latest add form-validation`

## FAQ

### How do I validate only the current step in react-hook-form?

Call trigger() with an array of the current step's field names before you move on, and only advance when it resolves to true. With a zod resolver the whole schema still runs, but react-hook-form only reports errors for the fields you passed.

### Should a multi step form use one useForm or one per step?

One useForm for the whole form is simpler: values survive when a step unmounts, the review step can read everything with useWatch, and the final submit gets one object. react-hook-form keeps the values of unmounted fields by default, since shouldUnregister is false.

### How do I save multi step form progress across a page reload?

Subscribe to form changes with watch(callback), write the values and the current step id to localStorage, and read them back after the component mounts so the server HTML still matches. Never store passwords or card numbers, and clear the draft after a successful submit.

### How do I add a progress bar to a React multi step form?

Show the current position as a progressbar with aria-valuenow and a visible Step 2 of 4 label. In the Wingo UI Form Wizard, stepper="progress" renders that header, and on phones every stepper turns into it automatically.

### Does shadcn/ui have a multi step form component?

Partly. Since August 2026 shadcn/ui ships Questionnaire, which shows one question at a time with progress, Previous and Next, and required checks. It has no stepper or wizard for steps that each hold several fields, so that kind of shadcn multi step form still needs your own step state or a community registry.

---

Source: https://wingo-ui.com/blog/react-multi-step-form
