Aquest article està patrocinat per SurveyJS Hi ha un model mental que la majoria dels desenvolupadors de React comparteixen sense parlar-ne mai en veu alta. Que les formes sempre han de ser components. Això significa una pila com:

Formulari React Hook per a l'estat local (representacions mínimes, registre de camp ergonòmic, interacció imperativa). Zod per a la validació (correcció d'entrada, validació de límits, anàlisi de tipus segur). React Query per al backend: enviament, reintents, memòria cau, sincronització del servidor, etc.

I per a la gran majoria de formularis: les vostres pantalles d'inici de sessió, les vostres pàgines de configuració, els vostres mods CRUD, això funciona molt bé. Cada peça fa la seva feina, es componen de manera neta i podeu passar a les parts de la vostra aplicació que realment diferencien el vostre producte. Però de tant en tant, un formulari comença a acumular coses com ara regles de visibilitat que depenen de respostes anteriors, o valors derivats que passen en cascada a través de tres camps. Potser fins i tot pàgines senceres que s'han de saltar o mostrar en funció d'un total acumulat. Gestioneu el primer condicional amb un useWatch i una branca en línia, que està bé. Després un altre. Aleshores esteu arribant a SuperRefine per codificar regles de camp creuat que el vostre esquema Zod no pot expressar de la manera normal. Aleshores, la navegació per passos comença a filtrar la lògica empresarial. En algun moment, observeu el que heu creat i us adoneu que el formulari ja no és realment una interfície d'usuari. És més aviat un procés de decisió i l'arbre de components és just on l'heu d'emmagatzemar. Aquí és on crec que el model mental de les formes a React es trenca, i realment no és culpa de ningú. La pila RHF + Zod és excel·lent pel que va ser dissenyada. El problema és que tendim a seguir utilitzant-lo més enllà del punt en què les seves abstraccions coincideixen amb el problema perquè l'alternativa requereix una manera totalment diferent de pensar les formes. Aquest article tracta sobre aquesta alternativa. Per mostrar-ho, construirem exactament el mateix formulari de diversos passos dues vegades:

Amb el formulari React Hook + Zod connectat a React Query per enviar-lo, Amb SurveyJS, que tracta un formulari com a dades (un simple esquema JSON) en lloc d'un arbre de components.

Els mateixos requisits, la mateixa lògica condicional, la mateixa trucada d'API al final. A continuació, mapejarem exactament què es va moure i què es va quedar, i establirem una manera pràctica de decidir quin model heu d'utilitzar i quan. El formulari que estem construint:

Aquest formulari utilitzarà un flux de 4 passos: Pas 1: Detalls

Nom (obligatori), Correu electrònic (obligatori, format vàlid).

Pas 2: Comanda

Preu unitari, quantitat, tipus impositiu, Derivat: Subtotal, impostos, Total.

Pas 3: compte i comentaris

Tens un compte? (Sí/No) Si sí → nom d'usuari + contrasenya, tots dos són obligatoris. Si No → el correu electrònic ja s'ha recollit al pas 1.

Grau de satisfacció (1–5) Si ≥ 4 → pregunta "Què t'ha agradat?" Si ≤ 2 → pregunta "Què podem millorar?"

Pas 4: Revisió

Només apareix si el total és >= 100 Presentació final.

Això no és extrem. Però n'hi ha prou per exposar les diferències arquitectòniques. Part 1: impulsat per components (Forma de ganxo de reacció + Zod) Instal·lació npm instal·la react-hook-form zod @hookform/resolvers @tanstack/react-query

Esquema Zod Comencem amb l'esquema Zod, perquè normalment és on s'estableix la forma de la forma. Per als dos primers passos, dades personals i entrades de comanda, tot és senzill: cadenes necessàries, números amb mínims i una enumeració. La part interessant comença quan intentes expressar les regles condicionals.

importa {z} de "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Obligatori"), correu electrònic: z.string().email("Correu electrònic no vàlid"), preu: z.number().min(0), quantitat: z.number().min(1), taxRate: z.number(), hasAccount: "["]), username: z.string().optional(), contrasenya: z.string().optional(), satisfacció: z.number().min(1).max(5), positiveFeedback: z.string().optional(), improvementFeedback: z.string().optional(),}).superRefine((data, ctx) => { if (data, ctx) => { if (data, ctx) => { if (data, ctx) => {) === "! ctx.addIssue({ codi: "personalitzat", ruta: ["nom d'usuari"], missatge: "Obligatori" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ codi: "personalitzat", camí: ["contrasenya"], missatge: "Mínim 6 caràcters}"});

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ codi: "custom", ruta: ["positiveFeedback"], missatge: "Si us plau comparteix el que t'agrada" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ codi: "personalitzat", ruta:["improvementFeedback"], missatge: "Si us plau, digueu-nos què hem de millorar"}); }});

tipus d'exportació FormData = z.infer;

Tingueu en compte que el nom d'usuari i la contrasenya s'escriuen com a opcionals () tot i que són necessaris condicionalment perquè l'esquema de nivell de tipus de Zod descriu la forma de l'objecte, no les regles que regeixen quan els camps són importants. El requisit condicional ha de viure dins de superRefine, que s'executa després de validar la forma i té accés a l'objecte complet. Aquesta separació no és un defecte; és només per a què està dissenyada l'eina: superRefine és on va la lògica de camp creuat quan no es pot expressar a l'estructura de l'esquema. El que també és notable aquí és el que aquest esquema no expressa. No té cap concepte de pàgines, cap concepte de quins camps són visibles en quin punt, ni concepte de navegació. Tot això viurà en un altre lloc. Component del formulari

importar { useForm, useWatch } de "react-hook-form";importar { zodResolver } de "@hookform/resolvers/zod";importar { useMutation } des de "@tanstack/react-query";importar { useState, useMemo } de "react";importar {formSchema, tipus Form./schema};

const STEPS = ["detalls", "comanda", "compte", "revisió"];

tipus OrderPayload = FormData & { subtotal: nombre; impost: número; total: nombre };

funció d'exportació RHFMultiStepForm() { const [step, setStep] = useState(0);

const mutation = useMutation ({ mutationFn: async (càrrega útil: OrderPayload) => { const res = await fetch ("/api/comandes", { mètode: "POST", capçaleres: { "Content-Type": "application/json" }, cos: JSON.stringify (càrrega útil), }); if (!res.ok) throw new Error("No s'ha pogut enviar"); retorna res.json(); }, });

const { register, control, handleSubmit, formState: { errors }, } = useForm({ resolutor: zodResolver(formSchema), defaultValues: {preu: 0, quantitat: 1, taxRate: 0.1, satisfaction: 3, hasAccount: "No", }, }); preu constant = useWatch({ control, nom: "preu" }); const quantitat = useWatch({ control, nom: "quantitat" }); const taxRate = useWatch({ control, nom: "taxRate" }); const hasCompte = useWatch({ control, nom: "hasAccount" }); const satisfaction = useWatch({ control, nom: "satisfacció" }); const subtotal = useMemo(() => (preu ?? 0) * (quantitat ?? 1), [preu, quantitat]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + impostos, [subtotal, impostos]); const onSubmit = (dades: FormData) => mutation.mutate({ ...dades, subtotal, impostos, total }); const showSubmit = (pas === 2 && total < 100) || (pas === 3 i& total >= 100)

retorn (

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

{pas === 1 && ( <> 5%

Subtotal: {subtotal}
Impostos: {tax}
Total: {total}
)}

{pas === 2 && ( <>

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

{satisfacció >= 4 && (