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
| Trappola | Effetto | Correzione |
|---|---|---|
| Un useForm per step | Valori, errori e stato touched si azzerano a ogni cambio. | Un solo useForm, step come indice. |
| handleSubmit sul pulsante Avanti | Valida anche i campi non ancora compilati e mostra errori invisibili. | trigger sui campi dello step. |
| required sui campi nascosti | Il browser blocca il submit senza spiegare quale campo manca. | noValidate e validazione Zod. |
| reset() a ogni step | Il 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.