React

Multi-step forms with shadcn

One useForm for every step, trigger() on the visible fields before moving on, and noValidate on the form: values persist and validation never blocks.

Key points

  • One `useForm` for the whole wizard: a new form per step loses instance, errors and values.
  • Before moving on, call `form.trigger(visibleFields)`, which validates only those and returns a boolean.
  • Put `noValidate` on the form: required fields in hidden steps block submit without a useful error.

Short answer: keep one useForm for every step, validate the visible fields with form.trigger() before moving on, and put noValidate on the form. The wizard state is an index, not a second schema: duplicating the form per step loses values and errors.

One schema, not several forms

The Zod schema describes every field, including the ones the user cannot see yet. Per-step validation needs no separate schemas: trigger takes a list of names and validates only those. The final submit stays a single handleSubmit call.

const schema = z.object({
  email: z.string().email("Email non valida"),
  name: z.string().min(2, "Almeno 2 caratteri"),
  plan: z.enum(["free", "pro"]),
});

const steps: { title: string; fields: ("email" | "name" | "plan")[] }[] = [
  { title: "Account", fields: ["email", "name"] },
  { title: "Piano", fields: ["plan"] },
];

The whole wizard

The component below stands on its own: a step index, a useForm with zodResolver, a Next button that calls trigger, a Back button that validates nothing. Hidden fields mount only when needed, yet their values stay in react-hook-form because shouldUnregister defaults to false.

"use client";

import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";

const schema = z.object({
  email: z.string().email("Email non valida"),
  name: z.string().min(2, "Almeno 2 caratteri"),
  plan: z.enum(["free", "pro"]),
});

const steps: { title: string; fields: ("email" | "name" | "plan")[] }[] = [
  { title: "Account", fields: ["email", "name"] },
  { title: "Piano", fields: ["plan"] },
];

export function SignupWizard() {
  const [step, setStep] = useState(0);
  const form = useForm>({
    resolver: zodResolver(schema),
    defaultValues: { email: "", name: "", plan: "free" },
  });

  async function next() {
    const valid = await form.trigger(steps[step].fields);
    if (valid) setStep((value) => Math.min(value + 1, steps.length - 1));
  }

  return (
     console.log(values))}>
      
        Passo {step + 1} di {steps.length}: {steps[step].title}
      

      {step === 0 && (
        
           (
              
                Email
                
                  
                
                
              
            )}
          />
           (
              
                Nome
                
                  
                
                
              
            )}
          />
        
      )}

      {step === 1 && Piano scelto: {form.watch("plan")}}

       setStep((value) => value - 1)}>
        Indietro
      
      {step 
          Avanti
        
      ) : (
        Crea account
      )}
    
  );
}

Why noValidate

The browser validates required fields before submit. When a required field belongs to a step that is not mounted, Chrome blocks the send and logs “An invalid form control with name='' is not focusable”: the user sees a button that does nothing. noValidate turns that check off and leaves validation to Zod, which knows which fields matter at that moment.

The traps

TrapEffectFix
One useForm per stepValues, errors and touched state reset on every change.One useForm, step as an index.
handleSubmit on the Next buttonValidates fields the user has not filled yet and shows invisible errors.trigger on the current step's fields.
required on hidden fieldsThe browser blocks submit without saying which field is missing.noValidate plus Zod validation.
reset() on every stepThe wizard restarts and the user retypes everything.reset() only after a successful submit.

Accessibility

A step change swaps the content but not the focus: keyboard users stay on the Next button and never learn the page changed. Announce the step with aria-live="polite", as in the code above, and move focus to the new step's heading. Errors stay attached to their field through FormMessage, which already uses aria-describedby.

A named limitation

Wizard state lives in the client: a refresh wipes the values, because react-hook-form saves nothing. For a long form, store a draft in sessionStorage or on the server, but do not expect the library to do it. The catalogue, measured on 1 November 2026, holds 17 form blocks: none implements a multi-step wizard, so the code above needs adapting to your schema.

Where to continue

In the catalogue you find the multi-step form record, a form block with preview and the form category. For the basics read what shadcn/ui is and how to theme shadcn/ui; the block comparison is in the best form blocks of 2026.

Frequently asked questions

Does each step need its own Zod schema?

No. One schema with every field is enough: form.trigger(["email", "name"]) validates only the listed fields and leaves the rest alone. Separate schemas force you to merge values at the end.

Why do values disappear when I change step?

Because you use a different useForm per step, or you set shouldUnregister to true. With one useForm and the default false, unmounted fields keep their value.

Why does the submit button do nothing?

Almost always a required field inside a hidden step: the browser blocks submit and cannot move focus to the field. Add noValidate to the form and leave validation to Zod.

How do I show errors only after Next?

Use mode: "onTouched", or keep mode: "onSubmit" and call trigger on the step's fields. The message appears after the first attempt, not while the user types.

How do I announce a step change?

Put aria-live="polite" on a paragraph that describes the step and move focus to the new step's heading. Keyboard and screen reader users then know the content changed.