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
return (
);}Consulte Pen SurveyJS-03-RHF [bifurcado] por sexta extinción. Están sucediendo muchas cosas aquí y vale la pena detenerse para notar dónde terminaron las cosas.
Los valores derivados (subtotal, impuestos, total) se calculan en el componente mediante useWatch y useMemo porque dependen de valores de campo activos y no hay otro lugar natural para ellos. Las reglas de visibilidad para nombre de usuario, contraseña, comentarios positivos y comentarios de mejora se encuentran en JSX como condicionales en línea. La lógica de omisión de pasos (la página de revisión solo aparece cuando el total es >= 100) está integrada en la variable showSubmit y la condición de renderizado en el paso 3. La navegación en sí es solo un contador useState que estamos incrementando manualmente. React Query maneja los reintentos, el almacenamiento en caché y la invalidación. El formulario simplemente llama amutación.mutate con datos validados.
Nada de esto está mal per se. Esto sigue siendo React idiomático, y el componente tiene bastante rendimiento gracias a cómo RHF aísla los renderizados. Pero si le entregara esto a alguien que no lo haya escrito y le pidiera que le explicara bajo qué condiciones aparece la página de revisión, tendría que rastrear showSubmit, la condición de renderizado del paso 3 y la lógica del botón de navegación (tres lugares separados) para reconstruir una regla que podría haberse establecido en una línea. La forma funciona, sí, pero el comportamiento no es realmente inspeccionable como sistema. Hay que ejecutarlo mentalmente. Más importante aún, cambiarlo requiere la participación de la ingeniería. Incluso un pequeño ajuste, como ajustar cuándo aparece el paso de revisión, significa editar el componente, actualizar la validación, abrir una solicitud de extracción, esperar la revisión y volver a implementarlo. Parte 2: Basado en esquemas (SurveyJS) Ahora construyamos el mismo flujo usando un esquema. Instalación npm instala encuesta-core encuesta-react-ui @tanstack/react-query
Survey-core El motor de tiempo de ejecución independiente de la plataforma con licencia del MIT que impulsa la representación de formularios de SurveyJS: la parte que nos interesa aquí. Toma un esquema JSON, construye un modelo interno a partir de él y maneja todo lo que de otra manera viviría en su componente React: evaluar expresiones de visibilidad, calcular valores derivados, administrar el estado de la página, rastrear la validación y decidir qué significa "completo" dadas las páginas que realmente se mostraron.
Survey-react-uiLa interfaz de usuario/capa de renderizado que conecta ese modelo a React. Es esencialmente un componente
Juntos, le brindan un tiempo de ejecución de formulario de varias páginas completamente funcional sin escribir una sola línea de flujo de control. El formato del esquema en sí es, como se dijo antes, solo JSON, sin DSL ni nada propietario. Puede incorporarlo, importarlo desde un archivo, obtenerlo de una API o almacenarlo en una columna de base de datos e hidratarlo en tiempo de ejecución. La misma forma que los datos Este es el mismo formulario, esta vez expresado como un objeto JSON. El esquema lo define todo: estructura, validación, reglas de visibilidad, cálculos derivados, navegación de páginas, y lo entrega a un modelo que lo evalúa en tiempo de ejecución. Así es como se ve en su totalidad:
export const SurveySchema = { título: "Flujo de pedidos", showProgressBar: "arriba", páginas: [ { nombre: "detalles", elementos: [ { tipo: "texto", nombre: "primer nombre", isRequired: true }, { tipo: "texto", nombre: "correo electrónico", inputType: "correo electrónico", isRequired: verdadero, validadores: [{ tipo: "correo electrónico", texto: "Correo electrónico no válido" }] } ] }, { nombre: "pedido", elementos: [ { tipo: "texto", nombre: "precio", tipo de entrada: "número", valor predeterminado: 0 }, { tipo: "texto", nombre: "cantidad", tipo de entrada: "número", valor predeterminado: 1 }, { tipo: "desplegable",nombre: "taxRate", valor predeterminado: 0,1, opciones: [ { valor: 0,05, texto: "5%" }, { valor: 0,1, texto: "10%" }, { valor: 0,15, texto: "15%" } ] }, { tipo: "expresión", nombre: "subtotal", expresión: "{precio} {cantidad}" }, { tipo: "expresión", nombre: "impuesto", expresión: "{subtotal} {taxRate}" }, { tipo: "expresión", nombre: "total", expresión: "{subtotal} + {impuesto}" } ] }, { nombre: "cuenta", elementos: [ { tipo: "radiogrupo", nombre: "hasAccount", opciones: ["Sí", "No"] }, { tipo: "texto", nombre: "nombre de usuario", visibleIf: "{hasAccount} = 'Sí'", isRequired: true }, { tipo: "texto", nombre: "contraseña", inputType: "contraseña", visibleIf: "{hasAccount} = 'Yes'", isRequired: true, validadores: [{ tipo: "texto", minLength: 6, texto: "Min 6 caracteres" }] }, { tipo: "rating", nombre: "satisfacción", rateMin: 1, rateMax: 5 }, { type: "comentario", nombre: "positiveFeedback", visibleIf: "{satisfacción} >= 4" }, { tipo: "comentario", nombre: "improvementFeedback", visibleIf: "{satisfaction} <= 2" } ] }, { nombre: "revisión", visibleIf: "{total} >= 100", elementos: [] } ]};
Compare esto con la versión RHF por un momento.
El bloque superRefine que requería nombre de usuario y contraseña condicionalmente desapareció. visibleIf: "{hasAccount} = 'Yes'" combinado con isRequired: true maneja ambas inquietudes juntas, en el campo mismo, donde esperaría encontrarlas. La cadena useWatch + useMemo que calculó el subtotal, los impuestos y el total se reemplaza por tres campos de expresión que hacen referencia entre sí por su nombre. La condición de la página de revisión, que en la versión RHF solo se podía reconstruir mediante el seguimiento a través de showSubmit, la rama de procesamiento del paso 3. Y finalmente, la lógica del botón de navegación es una única propiedad visibleIf en el objeto de la página.
La misma lógica está ahí. Lo que pasa es que el esquema le da un lugar donde vivir donde es visible de forma aislada, en lugar de estar extendido por todo el componente. Además, tenga en cuenta que el esquema utiliza el tipo: 'expresión' para el subtotal, el impuesto y el total. La expresión es de solo lectura y se usa principalmente para mostrar valores calculados. SurveyJS también admite el tipo: 'html' para contenido estático, pero para valores calculados, la expresión es la opción correcta. Ahora por el lado de React. Representación y envío Muy sencillo. Conecte onComplete a su API de la misma manera: mediante useMutation o simple fetch:
importar {useState, useEffect, useRef} de "react"; importar {useMutation} de "@tanstack/react-query"; importar {Modelo} de "survey-core"; importar {Encuesta} de "survey-react-ui"; importar "survey-core/survey-core.css";
función de exportación SurveyForm() { const [modelo] = useState(() => nuevo modelo(surveySchema));
mutación constante = usarMutación({ mutaciónFn: asíncrono (datos) => { const res = await fetch("/api/orders", { método: "POST", encabezados: { "Tipo de contenido": "aplicación/json" }, cuerpo: JSON.stringify(datos), }); if (!res.ok) arroja un nuevo error ("Error al enviar"); devolver res.json(); }, });
const mutaciónRef = useRef(mutación); mutaciónRef.current = mutación; useEffect(() => { const handler = (remitente) => mutaciónRef.current.mutate(remitente.data); model.onComplete.add(handler); return () => model.onComplete.remove(handler); }, [modelo]); // ref evita volver a registrar el controlador en cada renderizado (cambios de identidad del objeto de mutación)
regresar (
<>
Consulte Pen SurveyJS-03-SurveyJS [bifurcado] por sexta extinción.
onComplete se activa cuando el usuario llega al final de la última página visible. Entonces, si el total nunca supera 100 y se omite la página de revisión, aún así se activa correctamente porque SurveyJS evalúa la visibilidad antes de decidir qué significa "última página". Luego, sender.data contiene todas las respuestas junto con los valores calculados (subtotal, impuestos, total) como campos de primera clase, por lo que la carga útil de la API es idéntica a la que la versión RHF ensambló manualmente en onSubmit. ElEl patrón mutaciónRef es el mismo que usarías en cualquier lugar donde necesites un controlador de eventos estable sobre un valor que cambie en cada renderizado; no hay nada específico de SurveyJS al respecto.
El componente React ya no contiene ninguna lógica empresarial. No hay useWatch, ni JSX condicional, ni contador de pasos, ni cadena useMemo, ni superRefine. React está haciendo lo que realmente hace bien: renderizar un componente y conectarlo a una llamada API. ¿Qué salió de React?
Preocupación Pila RHF EncuestaJS Visibilidad Sucursales JSX visible si Valores derivados useWatch / useMemo expresión Reglas entre campos superrefinar Condiciones del esquema Navegación estado de paso Página visible si Ubicación de la regla Distribuido entre archivos Centralizado en el esquema
Lo que permanece en React es el diseño, el estilo, el cableado de envío y la integración de aplicaciones, es decir, las cosas para las que React está realmente diseñado. Todo lo demás se trasladó al esquema y, dado que el esquema es solo un objeto JSON, se puede almacenar en una base de datos, versionar independientemente del código de su aplicación o editar mediante herramientas internas sin necesidad de implementación. Un gerente de producto que necesite cambiar el umbral que activa la página de revisión puede hacerlo sin tocar el componente. Esa es una diferencia operativa significativa para los equipos donde el comportamiento de los formularios evoluciona con frecuencia y no siempre está impulsado por ingenieros. ¿Cuándo utilizar cada enfoque? Esta es una buena regla general que me funciona: imagina eliminar el formulario por completo. ¿Qué perderías?
Si se trata de pantallas, querrás formularios basados en componentes. Si es la lógica empresarial, como umbrales, reglas de ramificación y requisitos condicionales, lo que codifica decisiones reales, usted necesita un motor de esquemas.
De manera similar, si los cambios que se avecinan se refieren principalmente a etiquetas, campos y diseño, RHF le será de gran utilidad. Si se trata de condiciones, resultados y reglas que su equipo legal o de operaciones podría necesitar ajustar un martes por la tarde sin presentar un ticket, el modelo de esquema con SurveyJS es la opción más honesta. Estos dos enfoques realmente no compiten entre sí. Abordan diferentes clases de problemas, y el error que vale la pena evitar es no hacer coincidir la abstracción con el peso de la lógica: tratar un sistema de reglas como un componente porque es la herramienta familiar, o recurrir a un motor de políticas porque una forma creció a tres pasos y adquirió un campo condicional. La forma que construimos aquí se ubica deliberadamente cerca del límite, lo suficientemente compleja como para exponer la diferencia, pero no tan extrema como para que la comparación parezca manipulada. La mayoría de las formas reales que se han vuelto difíciles de manejar en su código base probablemente se encuentran cerca de ese mismo límite, y la pregunta generalmente es si alguien ha nombrado lo que realmente son. Utilice React Hook Form + Zod cuando:
Los formularios están orientados a CRUD; La lógica es superficial y se basa en la interfaz de usuario; Los ingenieros son dueños de todo comportamiento; El backend sigue siendo la fuente de la verdad.
Utilice SurveyJS cuando:
Los formularios codifican decisiones comerciales; Las reglas evolucionan independientemente de la interfaz de usuario; La lógica debe ser visible, auditable o versionada; Los no ingenieros influyen en el comportamiento; El mismo formulario debe ejecutarse en varias interfaces.