Este artículo está patrocinado por SurveyJS. Hay un modelo mental que la mayoría de los desarrolladores de React comparten sin siquiera discutirlo en voz alta. Se supone que las formas siempre son componentes. Esto significa una pila como:

Formulario React Hook para el estado local (representaciones mínimas, registro de campo ergonómico, interacción imperativa). Zod para validación (corrección de entrada, validación de límites, análisis con seguridad de tipos). React Query para backend: envío, reintentos, almacenamiento en caché, sincronización del servidor, etc.

Y para la gran mayoría de formularios (sus pantallas de inicio de sesión, sus páginas de configuración, sus modales CRUD), esto funciona muy bien. Cada pieza hace su trabajo, se componen limpiamente y usted puede pasar a las partes de su aplicación que realmente diferencian su producto. Pero de vez en cuando, un formulario comienza a acumular cosas como reglas de visibilidad que dependen de respuestas anteriores o valores derivados que caen en cascada a través de tres campos. Tal vez incluso páginas enteras que deberían omitirse o mostrarse según un total acumulado. Manejas el primer condicional con useWatch y una rama en línea, lo cual está bien. Luego otro. Entonces estás recurriendo a superRefine para codificar reglas entre campos que tu esquema Zod no puede expresar de la manera normal. Luego, la navegación por pasos comienza a filtrar la lógica empresarial. En algún momento, miras lo que has creado y te das cuenta de que el formulario ya no es realmente una interfaz de usuario. Es más bien un proceso de decisión, y el árbol de componentes es justo donde lo almacenaste. Aquí es donde creo que el modelo mental de las formas en React falla, y en realidad no es culpa de nadie. La pila RHF + Zod es excelente para lo que fue diseñada. El problema es que tendemos a seguir usándolo más allá del punto en que sus abstracciones coinciden con el problema porque la alternativa requiere una forma completamente diferente de pensar sobre las formas. Este artículo trata sobre esa alternativa. Para mostrar esto, crearemos exactamente el mismo formulario de varios pasos dos veces:

Con React Hook Form + Zod conectado a React Query para su envío, Con SurveyJS, que trata un formulario como datos (un esquema JSON simple) en lugar de un árbol de componentes.

Mismos requisitos, misma lógica condicional, misma llamada API al final. Luego, mapearemos exactamente qué se movió y qué permaneció, y presentaremos una forma práctica de decidir qué modelo debe usar y cuándo. El formulario que estamos construyendo:

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

Nombre (obligatorio), Correo electrónico (requerido, formato válido).

Paso 2: Orden

precio unitario, cantidad, tasa impositiva, Derivado: subtotal, impuesto, Total.

Paso 3: Cuenta y comentarios

¿Tienes una cuenta? (Sí/No) Si es así → nombre de usuario + contraseña, ambos son obligatorios. Si no → correo electrónico ya recopilado en el paso 1.

Calificación de satisfacción (1–5) Si ≥ 4 → pregunte “¿Qué te gustó?” Si ≤ 2 → pregunte “¿Qué podemos mejorar?”

Paso 4: Revisar

Sólo aparece si el total >= 100 Presentación definitiva.

Esto no es extremo. Pero es suficiente para exponer las diferencias arquitectónicas. Parte 1: Impulsado por componentes (React Hook Form + Zod) Instalación npm instala reaccionar-hook-form zod @hookform/resolvers @tanstack/react-query

Esquema Zod Comencemos con el esquema Zod, porque normalmente es allí donde se establece la forma de la forma. Para los dos primeros pasos (datos personales e ingreso de pedidos), todo es sencillo: cadenas requeridas, números con mínimos y una enumeración. La parte interesante comienza cuando intentas expresar las reglas condicionales.

importar {z} desde "zod";

export const formSchema = z.object({ firstName: z.string().min(1, "Obligatorio"), correo electrónico: z.string().email("Correo electrónico no válido"), precio: z.number().min(0), cantidad: z.number().min(1), taxRate: z.number(), hasAccount: z.enum(["Sí", "No"]), nombre de usuario: z.string().optional(), contraseña: z.string (). mensaje: "Obligatorio" });

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ código: "personalizado", ruta: ["positiveFeedback"], mensaje: "Por favor comparte lo que te gustó" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ código: "personalizado", ruta:["improvementFeedback"], mensaje: "Por favor, díganos qué mejorar" }); }});

tipo de exportación FormData = z.infer;

Tenga en cuenta que el nombre de usuario y la contraseña se escriben como opcionales() aunque son obligatorios condicionalmente porque el esquema de nivel de tipo de Zod describe la forma del objeto, no las reglas que rigen cuándo son importantes los campos. El requisito condicional debe residir dentro de superRefine, que se ejecuta después de validar la forma y tiene acceso al objeto completo. Esa separación no es un defecto; es precisamente para lo que está diseñada la herramienta: superRefine es donde va la lógica entre campos cuando no se puede expresar en la estructura del esquema en sí. Lo que también es notable aquí es lo que este esquema no expresa. No tiene concepto de páginas, ni de qué campos son visibles en qué punto, ni de navegación. Todo eso vivirá en otro lugar. Componente de formulario

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

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

escriba OrderPayload = FormData & { subtotal: número; impuesto: número; total: número };

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

mutación constante = usarMutación({ mutaciónFn: asíncrono (carga útil: OrderPayload) => { const res = await fetch("/api/orders", { método: "POST", encabezados: { "Tipo de contenido": "aplicación/json" }, cuerpo: JSON.stringify (carga útil), }); if (!res.ok) arroja un nuevo error ("Error al enviar"); devolver res.json(); }, });

const {registro, control, handleSubmit, formState: {errores}, } = useForm({ resolver: zodResolver(formSchema), defaultValues: {precio: 0, cantidad: 1, taxRate: 0.1, satisfacción: 3, hasAccount: "No", }, }); precio constante = useWatch({ control, nombre: "precio" }); cantidad constante = useWatch({ control, nombre: "cantidad" }); const tasaimpositiva = useWatch({ control, nombre: "tasaimpositiva" }); const hasAccount = useWatch({ control, nombre: "hasAccount" }); satisfacción constante = useWatch({ control, nombre: "satisfacción" }); const subtotal = useMemo(() => (precio?? 0) * (cantidad?? 1), [precio, cantidad]); const tax = useMemo(() => subtotal * (tasa impositiva ?? 0), [subtotal, tasa impositiva]); const total = useMemo(() => subtotal + impuestos, [subtotal, impuestos]); const onSubmit = (datos: FormData) => mutación.mutate({ ...datos, subtotal, impuestos, total }); const showSubmit = (paso === 2 && total < 100) || (paso === 3 && total >= 100)

return (

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

{paso === 1 && ( <> 5% 10% 15%

Subtotal: {subtotal}
Impuestos: {impuestos}
Total: {total}
)}

{paso === 2 && ( <>

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

{satisfacción >= 4 && (