Este artigo está patrocinado por SurveyJS Hai un modelo mental que a maioría dos desenvolvedores de React comparten sen discutilo nunca en voz alta. Que as formas sempre deben ser compoñentes. Isto significa unha pila como:

Formulario React Hook para o estado local (representacións mínimas, rexistro de campo ergonómico, interacción imperativa). Zod para validación (corrección de entrada, validación de límites, análise de tipo seguro). React Query para o backend: envío, reintentos, caché, sincronización do servidor, etc.

E para a gran maioría dos formularios: as túas pantallas de inicio de sesión, as túas páxinas de configuración, os teus modais CRUD, isto funciona moi ben. Cada peza fai o seu traballo, compoñen de forma limpa e podes pasar ás partes da túa aplicación que realmente diferencian o teu produto. Pero, de vez en cando, un formulario comeza a acumular cousas como regras de visibilidade que dependen de respostas anteriores ou valores derivados que pasan a través de tres campos. Quizais incluso páxinas enteiras que deberían omitirse ou mostrarse en función dun total acumulado. Manexa o primeiro condicional cun useWatch e unha rama en liña, o que está ben. Despois outra. Entón estás a buscar superRefine para codificar regras de campo cruzado que o teu esquema Zod non pode expresar da forma normal. Entón, a navegación por pasos comeza a filtrar a lóxica empresarial. Nalgún momento, mira o que construíu e dáse conta de que o formulario xa non é realmente unha interface de usuario. É máis un proceso de decisión, e a árbore de compoñentes é só onde o almacenaches. Aquí é onde creo que o modelo mental para as formas en React rompe, e realmente non é culpa de ninguén. A pila RHF + Zod é excelente para o que foi deseñada. O problema é que tendemos a seguir usándoo máis aló do punto en que as súas abstraccións coinciden co problema porque a alternativa require unha forma totalmente diferente de pensar as formas. Este artigo trata sobre esa alternativa. Para mostrar isto, construiremos exactamente o mesmo formulario de varios pasos dúas veces:

Con React Hook Form + Zod conectado a React Query para o envío, Con SurveyJS, que trata un formulario como datos (un esquema JSON simple) en lugar de unha árbore de compoñentes.

Mesmos requisitos, mesma lóxica condicional, mesma chamada API ao final. A continuación, mapearemos exactamente o que se moveu e o que quedou, e presentaremos unha forma práctica de decidir que modelo debes usar e cando. O formulario que estamos a construír:

Este formulario utilizará un fluxo de 4 pasos: Paso 1: Detalles

Nome (obrigatorio), Correo electrónico (obrigatorio, formato válido).

Paso 2: Orde

Prezo unitario, Cantidade, Tipo impositivo, Derivado: Subtotal, imposto, Total.

Paso 3: conta e comentarios

Tes unha conta? (Si/Non) Se si → nome de usuario + contrasinal, ambos son necesarios. Se Non → correo electrónico xa recompilado no paso 1.

Clasificación de satisfacción (1–5) Se ≥ 4 → pregunta "Que che gustou?" Se ≤ 2 → pregunta "Que podemos mellorar?"

Paso 4: Revisión

Só aparece se total >= 100 Presentación final.

Isto non é extremo. Pero é suficiente para expoñer as diferenzas arquitectónicas. Parte 1: Controlado por compoñentes (Forma de gancho de reacción + Zod) Instalación npm instalar react-hook-form zod @hookform/resolvers @tanstack/react-query

Esquema Zod Comecemos co esquema Zod, porque normalmente é onde se establece a forma da forma. Para os dous primeiros pasos, datos persoais e entradas de pedido, todo é sinxelo: cadeas necesarias, números con mínimos e unha enumeración. A parte interesante comeza cando intentas expresar as regras condicionais.

importar {z} de "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Requirido"), correo electrónico: z.string().email("Correo electrónico non válido"), prezo: z.number().min(0), cantidade: z.number().min(1), taxRate: z.number(), hasAccount: "["]), username: z.string().optional(), contrasinal: z.string().optional(), satisfacción: z.number().min(1).max(5), positiveFeedback: z.string().optional(), improvementFeedback: z.string().optional(),}).superRefine((datos, ctx) => { if (data, ctx) => { if (data, ctx) => { if (data, ctx) => { if (data, ctx) => { if (datos) === "! ctx.addIssue({ código: "personalizado", ruta: ["nome de usuario"], mensaxe: "Obrigatorio" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ código: "personalizado", ruta: ["contrasinal"], mensaxe: "Mín 6 caracteres}"});

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ código: "personalizado", ruta: ["positiveFeedback"], mensaxe: "Comparte o que che gustou" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ código: "personalizado", ruta:["improvementFeedback"], mensaxe: "Por favor, díganos o que debemos mellorar"}); }});

tipo de exportación FormData = z.infer;

Teña en conta que o nome de usuario e o contrasinal escríbense como opcional() aínda que son obrigatorios condicionalmente porque o esquema de nivel de tipo de Zod describe a forma do obxecto, non as regras que rexen cando importan os campos. O requisito condicional ten que vivir dentro de superRefine, que se executa despois de que se valida a forma e ten acceso ao obxecto completo. Esa separación non é un defecto; é só para o que está deseñada a ferramenta: superRefine é onde vai a lóxica de campo cruzado cando non se pode expresar na propia estrutura do esquema. O que tamén é notable aquí é o que este esquema non expresa. Non ten concepto de páxinas, ningún concepto de que campos son visibles en que punto e ningún concepto de navegación. Todo iso vivirá noutro lugar. Compoñente do formulario

importar { useForm, useWatch } de "react-hook-form";importar { zodResolver } de "@hookform/resolvers/zod";importar { useMutation } de "@tanstack/react-query";importar { useState, useMemo } de "react";importar { formSchema, tipo "FormDataschema"};

const STEPS = ["detalles", "pedido", "conta", "revisión"];

tipo OrderPayload = FormData & { subtotal: número; imposto: número; total: número };

función de exportación RHFMultiStepForm() { const [paso, setStep] = useState(0);

mutación constante = useMutation({ mutationFn: async (carga útil: OrderPayload) => { const res = agardar fech ("/api/ordes", { método: "POST", cabeceiras: { "Content-Type": "application/json" }, corpo: JSON.stringify(carga útil), }); if (!res.ok) throw new Error("Produciuse un erro ao enviar"); devolver res.json(); }, });

const { register, control, handleSubmit, formState: { erros }, } = useForm({ resolver: zodResolver(formSchema), defaultValues: { prezo: 0, cantidade: 1, taxRate: 0.1, satisfacción: 3, hasAccount: "Non", }, }); prezo const = useWatch({ control, nome: "prezo" }); cantidade constante = useWatch({ control, nome: "cantidade" }); const taxRate = useWatch({ control, nome: "taxRate" }); const hasAccount = useWatch({ control, nome: "hasAccount" }); satisfacción constante = useWatch({ control, nome: "satisfacción" }); const subtotal = useMemo(() => (prezo ?? 0) * (cantidade ?? 1), [prezo, cantidade]); const tax = useMemo(() => subtotal * (taxRate ?? 0), [subtotal, taxRate]); const total = useMemo(() => subtotal + imposto, [subtotal, imposto]); const onSubmit = (datos: FormData) => mutation.mutate({ ...datos, subtotal, imposto, total }); const showSubmit = (paso === 2 && total < 100) || (paso === 3 e& total >= 100)

return (

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

{paso === 1 && ( <> : 5%

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

{paso === 2 && ( <>

{hasAccount === "Si" && ( <> )}

{satisfacción >= 4 && (