Questo articolo è sponsorizzato da SurveyJS Esiste un modello mentale che la maggior parte degli sviluppatori React condivide senza mai discuterne ad alta voce. Che le forme dovrebbero sempre essere componenti. Ciò significa uno stack come:

React Hook Form per lo stato locale (re-render minimi, registrazione del campo ergonomico, interazione imperativa). Zod per la convalida (correttezza dell'input, convalida dei limiti, analisi indipendente dai tipi). React Query per il backend: invio, nuovi tentativi, memorizzazione nella cache, sincronizzazione del server e così via.

E per la stragrande maggioranza dei moduli (le schermate di accesso, le pagine delle impostazioni, le modalità CRUD) funziona davvero bene. Ogni pezzo fa il suo lavoro, si compone in modo pulito e puoi passare alle parti della tua applicazione che differenziano effettivamente il tuo prodotto. Ma di tanto in tanto, un modulo inizia ad accumulare cose come regole di visibilità che dipendono dalle risposte precedenti o valori derivati ​​che si riversano attraverso tre campi. Forse anche intere pagine che dovrebbero essere saltate o mostrate in base a un totale parziale. Gestisci il primo condizionale con un useWatch e un ramo in linea, il che va bene. Poi un altro. Quindi stai cercando superRefine per codificare regole tra campi che il tuo schema Zod non può esprimere in modo normale. Quindi, la navigazione dei passaggi inizia a perdere la logica aziendale. Ad un certo punto, guardi ciò che hai costruito e ti rendi conto che il modulo non è più realmente un'interfaccia utente. È più un processo decisionale e l'albero dei componenti è proprio il luogo in cui ti è capitato di memorizzarlo. È qui che penso che il modello mentale per i moduli in React crolli, e non è davvero colpa di nessuno. Lo stack RHF + Zod è eccellente in ciò per cui è stato progettato. Il problema è che tendiamo a continuare a usarlo oltre il punto in cui le sue astrazioni corrispondono al problema perché l’alternativa richiede un modo completamente diverso di pensare alle forme. Questo articolo riguarda questa alternativa. Per dimostrarlo, creeremo esattamente lo stesso modulo in più passaggi due volte:

Con React Hook Form + Zod collegato a React Query per l'invio, Con SurveyJS, che tratta un modulo come dati (un semplice schema JSON) anziché come un albero di componenti.

Stessi requisiti, stessa logica condizionale, stessa chiamata API alla fine. Quindi mapperemo esattamente cosa si è spostato e cosa è rimasto, e definiremo un modo pratico per decidere quale modello utilizzare e quando. Il modulo che stiamo costruendo:

Questo modulo utilizzerà un flusso in 4 passaggi: Passaggio 1: dettagli

Nome (richiesto), Email (richiesto, formato valido).

Passaggio 2: ordine

Prezzo unitario, Quantità, Aliquota fiscale, Derivato: Totale parziale, Tasse, Totale.

Passaggio 3: account e feedback

Hai un conto? (Sì/No) Se Sì → nome utente + password, entrambi obbligatori. Se No → email già raccolta nel passaggio 1.

Grado di soddisfazione (1–5) Se ≥ 4 → chiedi “Cosa ti è piaciuto?” Se ≤ 2 → chiedi “Cosa possiamo migliorare?”

Passaggio 4: revisione

Appare solo se totale >= 100 Presentazione finale.

Questo non è estremo. Ma è sufficiente per esporre le differenze architettoniche. Parte 1: Guidato dai componenti (React Hook Form + Zod) Installazione npm installa react-hook-form zod @hookform/resolvers @tanstack/react-query

Schema Zod Cominciamo con lo schema Zod, perché di solito è lì che viene stabilita la forma del form. Per i primi due passaggi (dati personali e input dell'ordine) tutto è semplice: stringhe richieste, numeri con minimi e un'enumerazione. La parte interessante inizia quando provi a esprimere le regole condizionali.

importa { z } da "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Required"), email: z.string().email("Email non valida"), prezzo: z.number().min(0), quantità: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Sì", "No"]), nome utente: z.string().opzionale, password: z.string().opzionale(), soddisfazione: z.number().min(1).max(5), positiveFeedback: z.string().optional(), miglioramentoFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data.hasAccount === "Yes") { if (!data.username) { ctx.addIssue({ code: "custom", path: ["username"], message: "Required" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ codice: "personalizzato", percorso: ["password"], messaggio: "Min 6 caratteri" } } });

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ codice: "custom", percorso: ["positiveFeedback"], messaggio: "Condividi ciò che ti è piaciuto" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ codice: "personalizzato", percorso:["improvementFeedback"], messaggio: "Dicci cosa migliorare" }); }});

tipo di esportazione FormData = z.infer;

Nota che nome utente e password sono digitati come optional() anche se sono condizionalmente richiesti perché lo schema a livello di tipo di Zod descrive la forma dell'oggetto, non le regole che governano quando i campi contano. Il requisito condizionale deve vivere all'interno di superRefine, che viene eseguito dopo la convalida della forma e ha accesso all'oggetto completo. Quella separazione non è un difetto; è proprio ciò per cui lo strumento è stato progettato: superRefine è il luogo in cui va la logica cross-field quando non può essere espressa nella struttura dello schema stessa. Ciò che è degno di nota è anche ciò che questo schema non esprime. Non ha il concetto di pagine, nessun concetto di quali campi siano visibili in quale punto e nessun concetto di navigazione. Tutto ciò vivrà da qualche altra parte. Componente del modulo

import { useForm, useWatch } da "react-hook-form";import { zodResolver } da "@hookform/resolvers/zod";import { useMutation } da "@tanstack/react-query";import { useState, useMemo } da "react";import { formSchema, type FormData } da "./schema";

const STEPS = ["dettagli", "ordine", "account", "recensione"];

type OrderPayload = FormData & { subtotale: numero; tassa: numero; totale: numero };

funzione di esportazione RHFMultiStepForm() { const [step, setStep] = useState(0);

const mutazione = useMutation({ mutazioneFn: asincrono (carico utile: OrderPayload) => { const res = attendono fetch("/api/orders", { metodo: "POST", intestazioni: { "Content-Type": "application/json" }, corpo: JSON.stringify(carico utile), }); if (!res.ok) lancia un nuovo errore ("Impossibile inviare"); return res.json(); }, });

const { registro, controllo, handleSubmit, formState: { errori }, } = useForm({ risolutore: zodResolver(formSchema), defaultValues: { prezzo: 0, quantità: 1, taxRate: 0.1, soddisfazione: 3, hasAccount: "No", }, }); prezzo const = useWatch({ controllo, nome: "prezzo" }); const quantità = useWatch({ controllo, nome: "quantità" }); const taxRate = useWatch({ controllo, nome: "taxRate" }); const hasAccount = useWatch({ controllo, nome: "hasAccount" }); const soddisfazione = useWatch({ controllo, nome: "soddisfazione" }); const subtotale = useMemo(() => (prezzo ?? 0) * (quantità ?? 1), [prezzo, quantità]); const tax = useMemo(() => subtotale * (taxRate ?? 0), [subtotale, taxRate]); const totale = useMemo(() => totale parziale + tasse, [totale parziale, tasse]); const onSubmit = (dati: FormData) => mutazione.mutate({ ...dati, subtotale, imposta, totale }); const showSubmit = (passaggio === 2 && totale < 100) || (passo === 3 && totale >= 100)

return (

{step === 0 && ( <> )}

{passo === 1 && ( <>

Totale parziale: {subtotal}
Imposta: {tax}
Totale: {total}
)}

{passo === 2 && ( <>

{hasAccount === "Sì" && ( <> )}

{soddisfazione >= 4 && (