React

Form a più step con shadcn

Un solo useForm per tutti gli step, trigger() sui campi visibili prima di avanzare e noValidate sul form: i valori restano e la validazione non blocca.

In sintesi

  • Un solo `useForm` per l'intero wizard: cambiare form a ogni step perde istanza, errori e valori.
  • Prima di avanzare chiama `form.trigger(campiVisibili)`, che valida solo quelli e restituisce un booleano.
  • Metti `noValidate` sul form: i campi richiesti degli step nascosti bloccano il submit senza mostrare un errore utile.

Risposta breve: tieni un solo useForm per tutti gli step, valida i campi visibili con form.trigger() prima di avanzare e metti noValidate sul form. Lo stato del wizard è un indice, non un secondo schema: se duplichi il form per step perdi valori ed errori.

Lo schema, non i form

Lo schema Zod descrive tutti i campi, anche quelli che l'utente non vede ancora. La validazione per step non richiede schemi separati: trigger accetta una lista di nomi e valida solo quelli. Così il submit finale resta una sola chiamata a handleSubmit.

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"] },
];

Il wizard completo

Il componente sotto è autosufficiente: un indice step, un useForm con zodResolver, il pulsante Avanti che chiama trigger, il pulsante Indietro che non valida nulla. I campi nascosti restano montati solo quando servono, ma i valori restano in react-hook-form perché shouldUnregister è false per impostazione predefinita.

"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
      )}
    
  );
}

Perché noValidate

Il browser valida i campi required prima del submit. Se un campo obbligatorio appartiene a uno step che non è montato, Chrome blocca l'invio e scrive in console «An invalid form control with name='' is not focusable»: l'utente vede un pulsante che non fa nulla. noValidate disattiva quel controllo e lascia la validazione a Zod, che sa quali campi contano in quel momento.

Le trappole

TrappolaEffettoCorrezione
Un useForm per stepValori, errori e stato touched si azzerano a ogni cambio.Un solo useForm, step come indice.
handleSubmit sul pulsante AvantiValida anche i campi non ancora compilati e mostra errori invisibili.trigger sui campi dello step.
required sui campi nascostiIl browser blocca il submit senza spiegare quale campo manca.noValidate e validazione Zod.
reset() a ogni stepIl wizard riparte da zero e l'utente riscrive tutto.reset() solo dopo l'invio riuscito.

Accessibilità

Il cambio di step sposta il contenuto ma non il focus: chi usa la tastiera resta sul pulsante Avanti e non sa che la pagina è cambiata. Dichiara il passo con aria-live="polite", come nel codice sopra, e porta il focus sul titolo del nuovo step. Gli errori restano collegati al campo da FormMessage, che usa già aria-describedby.

Limite dichiarato

Lo stato del wizard vive nel client: un refresh azzera i valori, perché react-hook-form non salva da nessuna parte. Se il form è lungo, salva una bozza in sessionStorage o sul server, ma non aspettarti che la libreria lo faccia. Il catalogo, misurato il 1 novembre 2026, contiene 17 blocchi form: nessuno implementa un wizard a più passaggi, quindi il codice sopra va adattato al tuo schema.

Dove continuare

Nel catalogo trovi il record del multi-step form, un blocco form con anteprima e la categoria form. Per le basi leggi che cosa è shadcn/ui e come tematizzare shadcn/ui; il confronto tra blocchi è in migliori form blocks 2026.

Domande frequenti

Serve uno schema Zod per ogni step?

No. Un solo schema con tutti i campi basta: form.trigger(["email", "name"]) valida solo quelli indicati e lascia intatti gli altri. Schemi separati costringono a ricomporre i valori alla fine.

Perché i valori spariscono cambiando step?

Perché stai usando un useForm diverso per ogni step, oppure hai impostato shouldUnregister a true. Con un solo useForm e il valore predefinito false i campi smontati mantengono il valore.

Perché il pulsante di invio non fa nulla?

Quasi sempre per un campo required in uno step nascosto: il browser blocca il submit e non può portare il focus sul campo. Aggiungi noValidate al form e lascia la validazione a Zod.

Come faccio vedere gli errori solo dopo Avanti?

Usa mode: "onTouched" oppure lascia mode: "onSubmit" e chiama trigger sui campi dello step. Il messaggio compare dopo il primo tentativo, non mentre l'utente digita.

Come annuncio il cambio di step?

Metti aria-live="polite" su un paragrafo che descrive il passo e sposta il focus sul titolo del nuovo step. Così chi usa la tastiera o lo screen reader sa che il contenuto è cambiato.