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
return (
);}Vedi Pen SurveyJS-03-RHF [biforcato] per sestaestinzione. Stanno accadendo parecchie cose qui, e vale la pena rallentare per notare dove sono finite le cose.
I valori derivati (totale parziale, imposta, totale) vengono calcolati nel componente tramite useWatch e useMemo perché dipendono dai valori dei campi attivi e non esiste altra posizione naturale per essi. Le regole di visibilità per nome utente, password, feedback positivo e feedback di miglioramento vivono in JSX come condizionali in linea. La logica di salto dei passaggi (la pagina di revisione viene visualizzata solo quando il totale >= 100) è incorporata nella variabile showSubmit e nella condizione di rendering del passaggio 3. La navigazione stessa è solo un contatore useState che stiamo incrementando manualmente. React Query gestisce i nuovi tentativi, la memorizzazione nella cache e l'invalidamento. Il modulo chiama semplicementemutation.mutate con dati convalidati.
Niente di tutto ciò è sbagliato, di per sé. Si tratta ancora di un React idiomatico e il componente è abbastanza performante grazie al modo in cui RHF isola i re-render. Ma se dovessi consegnare questo a qualcuno che non l’ha scritto e chiedergli di spiegare in quali condizioni appare la pagina di revisione, dovrebbe tracciare attraverso showSubmit, la condizione di rendering del passaggio 3 e la logica del pulsante di navigazione – tre posti separati – per ricostruire una regola che avrebbe potuto essere espressa in una riga. La forma funziona, sì, ma il comportamento non è realmente ispezionabile come sistema. Deve essere eseguito mentalmente. Ancora più importante, modificarlo richiede il coinvolgimento dell’ingegneria. Anche una piccola modifica, come modificare la visualizzazione della fase di revisione, significa modificare il componente, aggiornare la convalida, aprire una richiesta pull, attendere la revisione e eseguire nuovamente la distribuzione. Parte 2: Basato su schemi (SurveyJS) Ora costruiamo lo stesso flusso utilizzando uno schema. Installazione npm installa Survey-core Survey-React-UI @tanstack/react-query
Survey-coreIl motore runtime indipendente dalla piattaforma con licenza MIT che alimenta il rendering dei moduli di SurveyJS: la parte che ci interessa qui. Prende uno schema JSON, ne costruisce un modello interno e gestisce tutto ciò che altrimenti rimarrebbe nel tuo componente React: valutazione delle espressioni di visibilità, calcolo dei valori derivati, gestione dello stato della pagina, monitoraggio della convalida e decisione su cosa significa "completo" date le pagine effettivamente mostrate.
Survey-react-uiIl livello UI/rendering che collega quel modello a React. Si tratta essenzialmente di un componente
Insieme, offrono un runtime di moduli multipagina completamente funzionale senza scrivere una singola riga di flusso di controllo. Il formato dello schema stesso è, come detto prima, solo JSON, senza DSL o altro. Puoi incorporarlo, importarlo da un file, recuperarlo da un'API o archiviarlo in una colonna del database e idratarlo in fase di esecuzione. La stessa forma dei dati Ecco la stessa forma, questa volta espressa come oggetto JSON. Lo schema definisce tutto: struttura, convalida, regole di visibilità, calcoli derivati, navigazione della pagina e lo trasmette a un modello che lo valuta in fase di esecuzione. Ecco come appare per intero:
export const SurveySchema = { titolo: "Flusso dell'ordine", showProgressBar: "top", pagine: [ { nome: "dettagli", elementi: [ { tipo: "testo", nome: "firstName", isRequired: true }, { tipo: "testo", nome: "email", inputType: "email", isRequired: true, validatori: [{ tipo: "email", testo: "E-mail non valida" }] } ] }, { nome: "ordine", elementi: [ { tipo: "testo", nome: "prezzo", inputType: "numero", defaultValue: 0 }, { tipo: "testo", nome: "quantità", inputType: "numero", defaultValue: 1 }, { tipo: "dropdown",nome: "taxRate", defaultValue: 0.1, scelte: [ { valore: 0.05, testo: "5%" }, { valore: 0.1, testo: "10%" }, { valore: 0.15, testo: "15%" } ] }, { tipo: "espressione", nome: "totale parziale", espressione: "{prezzo} {quantità}" }, { tipo: "espressione", nome: "tax", espressione: "{subtotal} {taxRate}" }, { tipo: "expression", nome: "total", espressione: "{subtotal} + {tax}" } ] }, { nome: "account", elementi: [ { tipo: "radiogroup", nome: "hasAccount", scelte: ["Sì", "No"] }, { tipo: "testo", nome: "nome utente", visibileSe: "{hasAccount} = 'Sì'", isRequired: true }, { tipo: "testo", nome: "password", inputType: "password", visibileSe: "{hasAccount} = 'Sì'", isRequired: true, validatori: [{ tipo: "testo", minLength: 6, testo: "Min 6 caratteri" }] }, { tipo: "rating", nome: "satisfaction", rateMin: 1, rateMax: 5 »
Confrontatelo per un momento con la versione RHF.
Il blocco superRefine che richiedeva nome utente e password in modo condizionale è scomparso. visibileIf: "{hasAccount} = 'Yes'" combinato con isRequired: true gestisce entrambe le preoccupazioni insieme, sul campo stesso, dove ti aspetteresti di trovarle. La catena useWatch + useMemo che calcolava il totale parziale, le tasse e il totale è sostituita da tre campi di espressione che fanno riferimento l'uno all'altro per nome. La condizione della pagina di revisione, che nella versione RHF era ricostruibile solo tracciando tramite showSubmit, il ramo di rendering dello step 3. Infine, la logica del pulsante di navigazione è una singola proprietà visibileIf sull'oggetto pagina.
C'è la stessa logica. È solo che lo schema gli dà un posto dove vivere dove è visibile isolatamente, piuttosto che diffuso nel componente. Inoltre, tieni presente che lo schema utilizza il tipo: 'expression' per il totale parziale, le tasse e il totale. L'espressione è di sola lettura e viene utilizzata principalmente per visualizzare i valori calcolati. SurveyJS supporta anche il tipo: 'html' per il contenuto statico, ma per i valori calcolati l'espressione è la scelta giusta. Ora passiamo al lato React. Rendering e presentazione Molto semplice. Collega il completamento alla tua API nello stesso modo: tramite useMutation o semplice recupero:
import { useState, useEffect, useRef } da "react";import { useMutation } da "@tanstack/react-query";import { Model } da "survey-core";import { Survey } da "survey-react-ui";import "survey-core/survey-core.css";
funzione di esportazione SurveyForm() { const [model] = useState(() => new Model(surveySchema));
const mutazione = useMutation({ mutazioneFn: asincrono (dati) => { const res = attendono fetch("/api/orders", { metodo: "POST", intestazioni: { "Content-Type": "application/json" }, corpo: JSON.stringify(dati), }); if (!res.ok) lancia un nuovo errore ("Impossibile inviare"); return res.json(); }, });
constmutationRef = useRef(mutazione); mutazioneRef.current = mutazione; useEffect(() => { const handler = (mittente) =>mutationRef.current.mutate(sender.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [model]); // ref evita di registrare nuovamente il gestore a ogni rendering (modifiche all'identità dell'oggetto di mutazione)
ritorno (
<>
Vedi Pen SurveyJS-03-SurveyJS [biforcato] da sestoestinzione.
onComplete si attiva quando l'utente raggiunge la fine dell'ultima pagina visibile. Pertanto, se il totale non supera mai 100 e la pagina di revisione viene saltata, si attiva comunque correttamente perché SurveyJS valuta la visibilità prima di decidere cosa significa "ultima pagina". Quindi, sender.data contiene tutte le risposte insieme ai valori calcolati (totale parziale, tasse, totale) come campi di prima classe, quindi il payload dell'API è identico a quello che la versione RHF ha assemblato manualmente in onSubmit. ILIl modello modificationRef è lo stesso che potresti raggiungere ovunque ti serva un gestore di eventi stabile su un valore che cambia a ogni rendering: non c'è nulla di specifico di SurveyJS al riguardo.
Il componente React non contiene più alcuna logica aziendale. Non è necessario utilizzare Watch, né JSX condizionale, né contapassi, né catena useMemo, né superRefine. React sta facendo ciò in cui è effettivamente bravo: eseguire il rendering di un componente e collegarlo a una chiamata API. Cosa è uscito da React?
Preoccupazione Pila RHF SurveyJS Visibilità Rami JSX visibileSe Valori derivati useWatch / useMemo espressione Regole del cross-field superRefine Condizioni dello schema Navigazione stato del passo Pagina visibileSe Posizione della regola Distribuito su file Centralizzato nello schema
Ciò che rimane in React è il layout, lo stile, il cablaggio di invio e l'integrazione delle app, vale a dire le cose per cui React è effettivamente progettato. Tutto il resto è stato spostato nello schema e, poiché lo schema è solo un oggetto JSON, può essere archiviato in un database, con versione indipendente dal codice dell'applicazione o modificato tramite strumenti interni senza richiedere una distribuzione. Un product manager che ha bisogno di modificare la soglia che attiva la pagina di revisione può farlo senza toccare il componente. Si tratta di una differenza operativa significativa per i team in cui il comportamento dei moduli si evolve frequentemente e non è sempre guidato dagli ingegneri. Quando utilizzare ciascun approccio? Ecco una buona regola pratica che funziona per me: immagina di eliminare completamente il modulo. Cosa perderesti?
Se si tratta di schermate, desideri moduli basati su componenti. Se è la logica aziendale, come soglie, regole di ramificazione e requisiti condizionali, a codificare decisioni reali, ti serve un motore di schema.
Allo stesso modo, se i cambiamenti in arrivo riguardano principalmente etichette, campi e layout, RHF ti sarà utile. Se riguardano condizioni, risultati e regole che il tuo team operativo o legale potrebbe dover modificare un martedì pomeriggio senza presentare un ticket, il modello di schema con SurveyJS è la soluzione più onesta. Questi due approcci non sono realmente in competizione tra loro. Affrontano diverse classi di problemi, e l’errore che vale la pena evitare è quello di non far corrispondere l’astrazione al peso della logica: trattare un sistema di regole come un componente perché è uno strumento familiare, o ricorrere a un motore politico perché un modulo è cresciuto fino a tre passaggi e ha acquisito un campo condizionale. La forma che abbiamo costruito qui si trova deliberatamente vicino al confine, abbastanza complessa da evidenziare la differenza ma non così estrema da far sembrare il confronto truccato. La maggior parte delle forme reali che sono diventate ingombranti nella tua codebase probabilmente si trovano vicino allo stesso confine, e la domanda di solito è solo se qualcuno ha nominato ciò che realmente sono. Usa React Hook Form + Zod quando:
I moduli sono orientati al CRUD; La logica è superficiale e guidata dall'interfaccia utente; Gli ingegneri possiedono tutti i comportamenti; Il backend rimane la fonte della verità.
Utilizza SurveyJS quando:
I moduli codificano le decisioni aziendali; Le regole si evolvono indipendentemente dall'interfaccia utente; La logica deve essere visibile, verificabile o con versione; I non ingegneri influenzano il comportamento; Lo stesso modulo deve essere eseguito su più frontend.