Construyendo formularios dinámicos en React y Next.js — Smashing Magazine
La mayoría de los desarrolladores de React tienen un modelo mental común pero nunca lo discuten en voz alta. La forma es siempre Debería ser un componente. Esto significa una pila como esta:
- Reaccionar forma de gancho Para el estado local (representación mínima, registro en vivo ergonómico, interacción imperativa).
- Zod Se utiliza para la validación (corrección de entrada, validación de límites, análisis con seguridad de tipos).
- consulta de reacción Para el backend: confirmaciones, reintentos, almacenamiento en caché, sincronización del servidor, etc.
Para la mayoría de los formularios (pantallas de inicio de sesión, páginas de configuración, patrones CRUD), esto funciona muy bien. Con cada parte haciendo su trabajo y claramente organizada, puedes pasar a las partes de tu aplicación que realmente hacen que tu producto se destaque.
Pero de vez en cuando, el formulario comienza a acumular cosas como reglas de visibilidad que se basan en respuestas anteriores o valores derivados que caen en cascada a través de tres campos. Tal vez incluso debería omitirse o mostrarse la página completa según el total acumulado.
Puedes manejar la primera condición usando useWatch y una rama en línea, lo cual está bien. Luego otro. entonces lo logras superRefine Codifique reglas entre campos que el esquema Zod no pueda expresar de forma 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 toma de decisiones y el árbol de componentes es el lugar donde se almacena.
Creo que aquí es donde el modelo mental de formas en React se desmorona, y en realidad no es culpa de nadie. La pila RHF + Zod es excelente para lo que está diseñada para hacer. El problema es que tendemos a seguir usándolo hasta que su abstracción se ajusta al problema. Porque la alternativa exige pensar la forma de una manera completamente diferente.
Este artículo trata sobre esta alternativa. Para ilustrar esto, crearemos exactamente el mismo formulario de varios pasos dos veces:
- Utilice React Hook Form + Zod para conectarse a React Query para su envío,
- Con SurveyJS, trata el formulario como datos (un esquema JSON simple) en lugar de un árbol de elementos.
Los mismos requisitos, la misma lógica condicional y, finalmente, la misma llamada API. Luego, trazaremos exactamente lo que se mueve y lo que se queda, y sugeriremos una forma práctica de decidir qué modelo debe usar y cuándo.
El formulario que estamos construyendo:

Este formulario utilizará un proceso de 4 pasos:
Paso 1: Detalles
- Nombre (obligatorio),
- Correo electrónico (obligatorio, en formato válido).
Paso 2: Orden
- precio unitario,
- cantidad,
- tasa impositiva,
- Derivado de:
Paso 3: cuenta y recompensas
- ¿Tienes una cuenta? (si)
- Si es → Nombre de usuario + Contraseña, ambos son obligatorios.
- Si no → El correo electrónico ya se recopiló en el paso 1.
- Puntuación de satisfacción (1-5)
- Si ≥ 4 → Pregunta “¿Qué te gusta?”
- Si ≤ 2 → Pregunte “¿Qué podemos mejorar?”
Paso 4: Revisar
- Aparece sólo en las siguientes situaciones
total >= 100 - Presentación definitiva.
Esto no es extremo. Pero es suficiente para exponer las diferencias arquitectónicas.
Parte 1: Controlador de componentes (forma de gancho de reacción + Zod)
Instalar
npm install react-hook-form zod @hookform/resolvers @tanstack/react-query
Modo Zod
Comencemos con el patrón Zod, ya que aquí es donde generalmente se construyen las formas. Para los dos primeros pasos (datos personales y entrada del pedido), todo es sencillo: la cadena requerida, el número mínimo y la enumeración. La parte divertida comienza cuando intentas expresar reglas condicionales.
import { z } from "zod";
export const formSchema = z.object({
firstName: z.string().min(1, "Required"),
email: z.string().email("Invalid email"),
price: z.number().min(0),
quantity: z.number().min(1),
taxRate: z.number(),
hasAccount: z.enum(("Yes", "No")),
username: z.string().optional(),
password: z.string().optional(),
satisfaction: z.number().min(1).max(5),
positiveFeedback: z.string().optional(),
improvementFeedback: 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({ code: "custom", path: ("password"), message: "Min 6 characters" });
}
}
if (data.satisfaction >= 4 && !data.positiveFeedback) {
ctx.addIssue({ code: "custom", path: ("positiveFeedback"), message: "Please share what you liked" });
}
if (data.satisfaction <= 2 && !data.improvementFeedback) {
ctx.addIssue({ code: "custom", path: ("improvementFeedback"), message: "Please tell us what to improve" });
}
});
export type FormData = z.infer<typeof formSchema>;
tenga en cuenta username y password Escribe como optional() Aunque son condicionalmente necesarios, dado que el esquema de nivel de tipo de Zod describe forma Reglas para el objeto, no reglas que controlan cuándo el campo entra en vigor.
Las condiciones requieren que debes vivir en él. superRefineque se ejecuta después de validar la forma y tiene acceso al objeto completo. Esta separación no es un defecto; Esto es exactamente para lo que está diseñada esta herramienta: superRefine La lógica entre dominios surge cuando no se puede expresar en la estructura del patrón en sí.
También vale la pena señalar aquí que este modelo No Expresar. No tiene concepto de páginas, ni de qué campos son visibles en qué puntos, ni de navegación. Todos estos vivirán en otro lugar.
Componente de formulario
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useState, useMemo } from "react";
import { formSchema, type FormData } from "./schema";
const STEPS = ("details", "order", "account", "review");
type OrderPayload = FormData & { subtotal: number; tax: number; total: number };
export function RHFMultiStepForm() {
const (step, setStep) = useState(0);
const mutation = useMutation({
mutationFn: async (payload: OrderPayload) => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error("Failed to submit");
return res.json();
},
});
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
price: 0,
quantity: 1,
taxRate: 0.1,
satisfaction: 3,
hasAccount: "No",
},
});
const price = useWatch({ control, name: "price" });
const quantity = useWatch({ control, name: "quantity" });
const taxRate = useWatch({ control, name: "taxRate" });
const hasAccount = useWatch({ control, name: "hasAccount" });
const satisfaction = useWatch({ control, name: "satisfaction" });
const subtotal = useMemo(() => (price ?? 0) * (quantity ?? 1), (price, quantity));
const tax = useMemo(() => subtotal * (taxRate ?? 0), (subtotal, taxRate));
const total = useMemo(() => subtotal + tax, (subtotal, tax));
const onSubmit = (data: FormData) => mutation.mutate({ ...data, subtotal, tax, total });
const showSubmit = (step === 2 && total < 100) || (step === 3 && total >= 100)
return (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 0 && (
<>
<input {...register("firstName")} placeholder="First Name" />
<input {...register("email")} placeholder="Email" />
</>
)}
{step === 1 && (
<>
<input type="number" {...register("price", { valueAsNumber: true })} />
<input type="number" {...register("quantity", { valueAsNumber: true })} />
<select {...register("taxRate", { valueAsNumber: true })}>
<option value="0.05">5%</option>
<option value="0.1">10%</option>
<option value="0.15">15%</option>
</select>
<div>Subtotal: {subtotal}</div>
<div>Tax: {tax}</div>
<div>Total: {total}</div>
</>
)}
{step === 2 && (
<>
<select {...register("hasAccount")}>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
{hasAccount === "Yes" && (
<>
<input {...register("username")} placeholder="Username" />
<input {...register("password")} placeholder="Password" />
</>
)}
<input type="number" {...register("satisfaction", { valueAsNumber: true })} />
{satisfaction >= 4 && (
<textarea {...register("positiveFeedback")} />
)}
{satisfaction <= 2 && (
<textarea {...register("improvementFeedback")} />
)}
</>
)}
{step === 3 && total >= 100 && <div>Review and submit</div>}
<div>
{step > 0 && <button type="button" onClick={() => setStep(step - 1)}>Back</button>}
{showSubmit ? (
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Submitting…" : "Submit"}
</button>
) : step < STEPS.length - 1 ? (
<button type="button" onClick={() => setStep(step + 1)}>Next</button>
) : null}
</div>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</form>
);
}
Ver bolígrafo (SurveyJS-03-RHF (bifurcado)) (https://codepen.io/smashingmag/pen/gbwwmNO) sexta extinción.
Están sucediendo muchas cosas aquí y vale la pena detenerse y prestar atención a cómo suceden las cosas.
- Valor de exportación—
subtotal,tax,total— calculando en el componenteuseWatchyuseMemoPorque dependen de los valores del campo vivo y no hay otro lugar natural para ellos. - reglas de visibilidad
username,password,positiveFeedbackyimprovementFeedbackExiste en JSX como condiciones en línea. - Lógica de salto: la página de comentarios solo aparecerá en las siguientes situaciones
total >= 100– Incrustar enshowSubmitVariables y condiciones de renderizado del paso 3. - La navegación en sí es sólo una
useStateEl contador lo estamos incrementando manualmente. - React Query maneja reintentos, almacenamiento en caché e invalidaciones. El formulario simplemente llama
mutation.mutatecon datos comprobados.
Ninguno de estos Equivocado, sí mismo. Esto sigue siendo React idiomático y el elemento funciona muy bien gracias a la forma en que RHF aísla el renderizado.
Pero si le das esto a alguien que no lo ha escrito y le pides que te explique ¿En qué condiciones aparecerá la página de comentarios?deben rastrear showSubmitel paso 3 representa la lógica del botón de navegación y condicional (tres ubicaciones separadas) para reconstruir las reglas que se pueden establecer en una línea.
Sí, el formulario funciona, pero su comportamiento como sistema no se puede comprobar realmente. Debe realizarse mentalmente.
Es más, cambiarlo requiere aportaciones de ingeniería. Incluso un pequeño ajuste, como ajustar cuándo ocurre un paso de revisión, significa editar el componente, actualizar las validaciones, abrir una solicitud de extracción, esperar la revisión y volver a implementarlo.
Parte 2: Basado en esquemas (SurveyJS)
Ahora construyamos el mismo proceso usando patrones.
Instalar
npm install survey-core survey-react-ui @tanstack/react-query
survey-core
Un motor de ejecución independiente de la plataforma con licencia del MIT que impulsa la representación de formularios de SurveyJS, que es la parte que nos ocupa aquí. Toma un esquema JSON, construye un modelo interno a partir de él y maneja todo en un elemento de React: evaluar expresiones de visibilidad, calcular valores derivados, administrar el estado de la página, rastrear la validación y determinar qué significa “completo” en función de la página real que se muestra.survey-react-ui
Conecte el modelo a la interfaz de usuario/capa de renderizado de React. es esencialmente un<Survey model={model} />Un componente que se vuelve a renderizar cada vez que cambia el estado del motor. La biblioteca SurveyJS UI también está disponible para bocina, Ver 3y muchos otros marcos.
Juntos le brindan un tiempo de ejecución de formulario de varias páginas completamente funcional sin tener que escribir una sola línea de flujo de control.
Como se dijo antes, el formato del esquema en sí es solo JSON, no hay DSL ni nada propietario. Puede incorporarlo, importarlo desde un archivo, obtenerlo de la API o almacenarlo en una columna de base de datos e hidratarlo en tiempo de ejecución.
misma forma que los datos
Este es el mismo formulario, esta vez representado como un objeto JSON. El patrón lo define todo: estructura, validación, reglas de visibilidad, cálculos derivados, navegación de páginas, y lo deja en manos de Model Evalúelo en tiempo de ejecución. El contenido completo es el siguiente:
export const surveySchema = {
title: "Order Flow",
showProgressBar: "top",
pages: (
{
name: "details",
elements: (
{ type: "text", name: "firstName", isRequired: true },
{ type: "text", name: "email", inputType: "email", isRequired: true, validators: ({ type: "email", text: "Invalid email" }) }
)
},
{
name: "order",
elements: (
{ type: "text", name: "price", inputType: "number", defaultValue: 0 },
{ type: "text", name: "quantity", inputType: "number", defaultValue: 1 },
{
type: "dropdown",
name: "taxRate",
defaultValue: 0.1,
choices: (
{ value: 0.05, text: "5%" },
{ value: 0.1, text: "10%" },
{ value: 0.15, text: "15%" }
)
},
{
type: "expression",
name: "subtotal",
expression: "{price} * {quantity}"
},
{
type: "expression",
name: "tax",
expression: "{subtotal} * {taxRate}"
},
{
type: "expression",
name: "total",
expression: "{subtotal} + {tax}"
}
)
},
{
name: "account",
elements: (
{
type: "radiogroup",
name: "hasAccount",
choices: ("Yes", "No")
},
{
type: "text",
name: "username",
visibleIf: "{hasAccount} = 'Yes'",
isRequired: true
},
{
type: "text",
name: "password",
inputType: "password",
visibleIf: "{hasAccount} = 'Yes'",
isRequired: true,
validators: ({ type: "text", minLength: 6, text: "Min 6 characters" })
},
{
type: "rating",
name: "satisfaction",
rateMin: 1,
rateMax: 5
},
{
type: "comment",
name: "positiveFeedback",
visibleIf: "{satisfaction} >= 4"
},
{
type: "comment",
name: "improvementFeedback",
visibleIf: "{satisfaction} <= 2"
}
)
},
{
name: "review",
visibleIf: "{total} >= 100",
elements: ()
}
)
};
Compare esto con la versión RHF.
- este
superRefinebloques condicionalmente requeridosusernameypasswordDesaparecido.visibleIf: "{hasAccount} = 'Yes'"combinarisRequired: trueAborda ambas cuestiones simultáneamente en la propia zona donde esperas encontrarlas. - este
useWatch+useMemocadena de cálculossubtotal,taxytotalreemplazado por tresexpressionCampos que hacen referencia entre sí por nombre. - Las condiciones de la página de revisión, en la versión RHF, solo se pueden reconstruir mediante seguimiento
showSubmitpaso 3 renderizar rama. - Finalmente, la lógica del botón de navegación es una
visibleIfPropiedades del objeto de página.
La misma lógica está ahí. Es sólo que el patrón le da un único lugar visible, en lugar de distribuirse por todo el componente.
También tenga en cuenta que este esquema utiliza type: 'expression' Se utiliza para subtotales, impuestos y totales. Expresar Es de solo lectura y se utiliza principalmente para mostrar valores calculados. SurveyJS también es compatible type: 'html' Para contenido estático, pero para valores calculados, expression es la elección correcta.
Ahora hablemos del aspecto React.
Renderizar y enviar
Muy sencillo. alambre metálico onComplete Conéctese a su API de la misma manera: a través de useMutation O ordinario fetch:
import { useState, useEffect, useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { Model } from "survey-core";
import { Survey } from "survey-react-ui";
import "survey-core/survey-core.css";
export function SurveyForm() {
const (model) = useState(() => new Model(surveySchema));
const mutation = useMutation({
mutationFn: async (data) => {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to submit");
return res.json();
},
});
const mutationRef = useRef(mutation);
mutationRef.current = mutation;
useEffect(() => {
const handler = (sender) => mutationRef.current.mutate(sender.data);
model.onComplete.add(handler);
return () => model.onComplete.remove(handler);
}, (model)); // ref avoids re-registering handler every render (mutation object identity changes)
return (
<>
<Survey model={model} />
{mutation.isError && <div>Error: {mutation.error.message}</div>}
</>
);
}
Ver Pluma (SurveyJS-03-SurveyJS (bifurcado)) (https://codepen.io/smashingmag/pen/emddWNV) sexta extinción.
onCompleteSe dispara cuando el usuario llega al final del último visible Página. Entonces sitotalNunca exceda 100 y omita la página de revisión; aún así se activará correctamente porque SurveyJS evalúa la visibilidad antes de decidir qué significa “última página”.- Entonces,
sender.dataContiene todas las respuestas más los valores calculados (subtotal,tax,total) como campo de primera clase, por lo que la carga útil de la API es la misma que la carga útil ensamblada manualmente en la versión RHFonSubmit. - este
mutationRefEl patrón es el mismo que usaría en cualquier lugar donde necesite un controlador de eventos estable cuyo valor cambie en cada renderizado; no hay nada específico de SurveyJS.
Los elementos de React ya no contienen ninguna lógica empresarial. No useWatchsin JSX condicional, sin podómetro, sin useMemo cadena, no superRefine. React está haciendo aquello en lo que es realmente bueno: representar elementos y conectarlos a llamadas API.
¿Qué se eliminó en React?
| inquietud | pila RHF | Cuestionario |
|---|---|---|
| visibilidad | Sucursales JSX | visibleIf |
| valor derivado | useWatch / useMemo |
expression |
| reglas entre dominios | superRefine |
condiciones arquitectónicas |
| navegación | step estado |
Página visibleIf |
| Posición de la regla | Distribuido entre archivos | centrarse en la arquitectura |
Lo que queda en React es el diseño, el estilo, el cableado de confirmación y la integración de aplicaciones, es decir, Para qué está realmente diseñado React.
Todo lo demás se mueve 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 la aplicación o editar mediante herramientas internas sin implementación.
Los gerentes de producto que necesiten cambiar el umbral que activa una página de revisión pueden hacerlo sin tocar el componente. Esta es una diferencia operativa significativa para los equipos donde el comportamiento de las formas cambia con frecuencia y no siempre está impulsado por el ingeniero.
¿Cuándo utilizar cada método?
Aquí hay una buena regla general que funciona para mí: Imagina quitar la mesa por completo.. ¿Qué tienes que perder?
- Si es una pantalla, necesita formularios basados en componentes.
- Si es la lógica empresarial, como umbrales, reglas de bifurcación y requisitos condicionales, la que codifica las decisiones reales, entonces necesita un motor de patrones.
Del mismo modo, si los cambios a los que se enfrenta están relacionados principalmente con etiquetas, campos y diseños, RHF le servirá bien. Si involucran condiciones, resultados y reglas que sus equipos de operaciones o legales pueden necesitar ajustar un martes por la tarde sin enviar un ticket, usar el modelo de esquema de SurveyJS es más apropiado.
En realidad, estos dos métodos no compiten entre sí. Resuelven diferentes clases de problemas, y el error que vale la pena evitar es no combinar la abstracción con la ponderación lógica: tratar un sistema de reglas como un componente porque es una herramienta familiar, o buscar un motor de estrategia porque la forma creció a tres pasos y ganó campos condicionales.
Las formas que construimos aquí se ubican deliberadamente cerca de la frontera, lo suficientemente complejas como para exponer las diferencias, pero no tan extremas como para que las comparaciones parezcan manipuladas. La mayoría de las formas reales que se vuelven difíciles de manejar en una base de código probablemente estén cerca del mismo límite, por lo general es solo una cuestión de si alguien nombró lo que realmente son.
Utilice React Hook Form + Zod en las siguientes situaciones:
- Los formularios están orientados a CRUD;
- La lógica es superficial y está impulsada por la interfaz de usuario;
- Los ingenieros son dueños de todos los comportamientos;
- El backend sigue siendo la fuente de la verdad.
Utilice SurveyJS cuando:
- Los formularios codifican decisiones comerciales;
- El desarrollo de reglas es independiente 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.
(yk)