Patrones de Formularios Complejos
Asistentes multietapa, arrays de campos dinámicos y campos condicionales — patrones para formularios que van más allá de lo básico.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Esquema de array de campos dinámicos
const InvoiceSchema = z.object({
client: z.string().min(1),
items: z
.array(
z.object({
description: z.string().min(1, "Required"),
quantity: z.coerce.number().int().positive(),
price: z.coerce.number().positive(),
})
)
.min(1, "At least one item"),
});
type Invoice = z.infer<typeof InvoiceSchema>;
function InvoiceForm() {
const { register, handleSubmit, control } = useForm<Invoice>({
resolver: zodResolver(InvoiceSchema),
defaultValues: { client: "", items: [{ description: "", quantity: 1, price: 0 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("client")} placeholder="Client" />
{fields.map((field, i) => (
<div key={field.id}>
<input {...register(`items.${i}.description`)} placeholder="Item" />
<input {...register(`items.${i}.quantity`)} type="number" />
<input {...register(`items.${i}.price`)} type="number" step="0.01" />
<button type="button" onClick={() => remove(i)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: "", quantity: 1, price: 0 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}Cuándo usarlo: Cuando tu formulario tiene grupos repetidos, múltiples pasos o campos que aparecen condicionalmente basados en otros valores de campos.
Ejemplo Funcional
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Formulario multietapa con esquemas por paso
const Step1Schema = z.object({
firstName: z.string().min(1, "First name required"),
lastName: z.string().min(1, "Last name required"),
email: z.string().email("Invalid email"),
});
const Step2Schema = z.object({
company: z.string().min(1, "Company required"),
role: z.enum(["developer", "designer", "manager", "other"]),
experience: z.coerce.number().int().min(0).max(50),
});
const Step3Schema = z.object({
plan: z.enum(["free", "pro", "enterprise"]),
newsletter: z.boolean().default(false),
referral: z.string().optional(),
});
const FullSchema = Step1Schema.merge(Step2Schema).merge(Step3Schema);
type FullForm = z.infer<typeof FullSchema>;
const steps = [
{ schema: Step1Schema, title: "Personal Info" },
{ schema: Step2Schema, title: "Professional" },
{ schema: Step3Schema, title: "Preferences" },
] as const;
export function MultiStepForm() {
const [step, setStep] = useState(0);
const [completedData, setCompletedData] = useState<Partial<FullForm>>({});
const currentStep = steps[step];
const {
register,
handleSubmit,
formState: { errors },
trigger,
} = useForm<FullForm>({
resolver: zodResolver(currentStep.schema as any),
defaultValues: completedData as FullForm,
mode: "onBlur",
});
async function handleNext(data: Partial<FullForm>) {
setCompletedData((prev) => ({ ...prev, ...data }));
if (step < steps.length - 1) {
setStep((s) => s + 1);
} else {
const finalData = { ...completedData, ...data } as FullForm;
console.log("Final submission:", finalData);
alert("Form submitted!");
}
}
function handleBack() {
setStep((s) => Math.max(0, s - 1));
}
return (
<div className="max-w-md">
{/* Barra de progreso */}
<div className="mb-6 flex gap-1">
{steps.map((s, i) => (
<div
key={s.title}
className={`h-2 flex-1 rounded ${i <= step ? "bg-blue-600" : "bg-gray-200"}`}
/>
))}
</div>
<h2 className="mb-4 text-lg font-bold">{currentStep.title}</h2>
<form onSubmit={handleSubmit(handleNext)} className="space-y-4">
{step === 0 && (
<>
<div>
<input {...register("firstName")} placeholder="First name" className="w-full rounded border p-2" />
{errors.firstName && <p className="text-sm text-red-600">{errors.firstName.message}</p>}
</div>
<div>
<input {...register("lastName")} placeholder="Last name" className="w-full rounded border p-2" />
{errors.lastName && <p className="text-sm text-red-600">{errors.lastName.message}</p>}
</div>
<div>
<input {...register("email")} placeholder="Email" className="w-full rounded border p-2" />
{errors.email && <p className="text-sm text-red-600">{errors.email.message}</p>}
</div>
</>
)}
{step === 1 && (
<>
<div>
<input {...register("company")} placeholder="Company" className="w-full rounded border p-2" />
{errors.company && <p className="text-sm text-red-600">{errors.company.message}</p>}
</div>
<div>
<select {...register("role")} className="w-full rounded border p-2">
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Manager</option>
<option value="other">Other</option>
</select>
</div>
<div>
<input {...register("experience")} type="number" placeholder="Years of experience" className="w-full rounded border p-2" />
{errors.experience && <p className="text-sm text-red-600">{errors.experience.message}</p>}
</div>
</>
)}
{step === 2 && (
<>
<fieldset className="space-y-2">
<legend className="font-medium">Plan</legend>
{(["free", "pro", "enterprise"] as const).map((plan) => (
<label key={plan} className="flex items-center gap-2">
<input type="radio" value={plan} {...register("plan")} />
{plan.charAt(0).toUpperCase() + plan.slice(1)}
</label>
))}
</fieldset>
<label className="flex items-center gap-2">
<input type="checkbox" {...register("newsletter")} />
Subscribe to newsletter
</label>
<input {...register("referral")} placeholder="Referral code (optional)" className="w-full rounded border p-2" />
</>
)}
<div className="flex gap-3">
{step > 0 && (
<button type="button" onClick={handleBack} className="rounded border px-4 py-2">
Back
</button>
)}
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
{step < steps.length - 1 ? "Next" : "Submit"}
</button>
</div>
</form>
</div>
);
}Lo que esto demuestra:
- Asistente multietapa con esquemas de Zod por paso
- Datos acumulados entre pasos
- Indicador de progreso
- Navegación Atrás/Siguiente con validación solo en el paso actual
- Esquema fusionado para el tipo de datos completo
Análisis Profundo
Cómo Funciona
- Los formularios multietapa dividen un esquema grande en esquemas por paso, validando solo el paso actual
useFieldArraygestiona arrays de objetos con identidad estable a través defield.idappend,remove,insert,move,swap,replaceyupdatemutan el array- Los campos condicionales usan
watchpara observar un campo controlador y mostrar/ocultar campos dependientes - Los esquemas por paso se fusionan al final para producir el tipo validado completo
Variaciones
Campos condicionales basados en otro campo:
const Schema = z.discriminatedUnion("type", [
z.object({ type: z.literal("individual"), ssn: z.string() }),
z.object({ type: z.literal("business"), ein: z.string(), companyName: z.string() }),
]);
function TaxForm() {
const { register, watch } = useForm({ resolver: zodResolver(Schema) });
const type = watch("type");
return (
<form>
<select {...register("type")}>
<option value="individual">Individual</option>
<option value="business">Business</option>
</select>
{type === "individual" && <input {...register("ssn")} placeholder="SSN" />}
{type === "business" && (
<>
<input {...register("ein")} placeholder="EIN" />
<input {...register("companyName")} placeholder="Company" />
</>
)}
</form>
);
}Arrays de campos anidados (tabla de tablas):
const Schema = z.object({
sections: z.array(
z.object({
title: z.string(),
items: z.array(z.object({ label: z.string(), value: z.string() })),
})
),
});
function NestedForm() {
const { control } = useForm({ resolver: zodResolver(Schema) });
const sections = useFieldArray({ control, name: "sections" });
return sections.fields.map((section, si) => {
const items = useFieldArray({ control, name: `sections.${si}.items` });
return (
<div key={section.id}>
{items.fields.map((item, ii) => (
<div key={item.id}>{/* campos anidados */}</div>
))}
</div>
);
});
}Notas de TypeScript
// Los campos de useFieldArray se tipan con un id generado automáticamente
type FieldWithId = { id: string; description: string; quantity: number; price: number };
// Las rutas dinámicas de register son type-safe
register(`items.${index}.description`); // OK
register(`items.${index}.typo`); // Error de TS
// Tipado de formulario con unión discriminada
type FormData = z.infer<typeof Schema>;
// { type: "individual"; ssn: string } | { type: "business"; ein: string; companyName: string }Trampa Comunes
-
La clave del array de campos debe ser
field.id— Usar el índice del array comokeycausa errores de estado cuando se eliminan o reordenan elementos. Solución: Siempre usafield.iddeuseFieldArray. -
Desajuste de esquema de validación multietapa — Si valideas con el esquema completo en cada paso, los pasos futuros sin tocar fallarán. Solución: Valida solo el esquema del paso actual.
-
Estado perdido en cambio de paso — Si desmontas el formulario entre pasos, los campos registrados pierden sus valores. Solución: Almacena datos validados en un estado padre (como se muestra) o usa un único formulario en todos los pasos.
-
Limpieza de campos condicionales — Cuando un campo se oculta, su valor permanece en los datos del formulario. Solución: Usa
unregistercuando ocultes campos, o filtra los datos antes del envío. -
Rendimiento de anidación profunda —
useFieldArrayprofundamente anidado puede causar re-renderizados excesivos. Solución: Extrae arrays anidados en sub-componentes memoizados.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
| Formulario único largo | El total de campos es menor a 10 y todos están visibles a la vez | La investigación UX muestra que los usuarios abandonan formularios largos |
| Páginas separadas por paso | Cada paso es una acción de servidor completa con progreso guardado | Quieres atrás/siguiente instantáneo sin cargas de página |
| Librerías de asistentes headless | Necesitas lógica de paso compleja (bifurcación, saltar, bucles) | Un asistente lineal simple es suficiente |
| Arrays FormData | Usas formularios nativos con name="items[]" | Necesitas UX de agregar/eliminar/reordenar |
Preguntas Frecuentes
¿Por qué deberías usar field.id en lugar del índice del array como clave en useFieldArray?
- El índice del array como
keycausa errores de estado de React cuando se eliminan o reordenan elementos field.ides un identificador único y estable generado poruseFieldArray- Siempre usa
key={field.id}en los elementos mapeados
¿Cómo valideas solo el paso actual en un formulario multietapa?
- Divide el esquema completo en esquemas por paso (ej.,
Step1Schema,Step2Schema) - Pasa solo el esquema del paso actual a
zodResolver:resolver: zodResolver(currentStep.schema) - Fusiona todos los esquemas de pasos al final para el tipo de datos completo
¿Cómo preservas datos entre pasos cuando el formulario se desmonta entre pasos?
- Almacena datos validados en estado padre:
setCompletedData(prev => ({ ...prev, ...data })) - Pasa datos acumulados como
defaultValuescuando recreas el formulario para cada paso - Alternativamente, usa un único formulario en todos los pasos y muestra/oculta secciones
¿Qué métodos proporciona useFieldArray para manipular arrays?
appendagrega un elemento al finalremove(index)elimina un elemento en una posicióninsert,move,swap,replaceyupdatetambién están disponibles- Todos los métodos mantienen valores
field.idestables para elementos existentes
¿Cómo implementas campos condicionales que se muestran/ocultan basados en otro valor de campo?
const type = watch("type");
{type === "business" && <input {...register("ein")} />}- Usa
watch("fieldName")para observar el campo controlador - Renderiza condicionalmente campos dependientes basados en el valor observado
¿Qué es z.discriminatedUnion y cuándo es útil para formularios condicionales?
const Schema = z.discriminatedUnion("type", [
z.object({ type: z.literal("individual"), ssn: z.string() }),
z.object({ type: z.literal("business"), ein: z.string() }),
]);- Valida formas diferentes basadas en un campo discriminador compartido
- Proporciona mejores mensajes de error que
z.uniony envía en O(1)
Trampa: ¿Qué sucede con los valores de campos ocultos cuando los campos condicionales se eliminan del DOM?
- El valor permanece en los datos del formulario incluso después de que el campo está oculto
- Esto puede causar datos inesperados en la carga del envío
- Solución: llama a
unregister("fieldName")cuando ocultes campos, o filtra los datos antes del envío
Trampa: ¿Qué causa re-renderizados excesivos con useFieldArray profundamente anidado?
- Cada
useFieldArrayse suscribe a cambios en su array, activando re-renderizados en cualquier mutación - Los arrays profundamente anidados componen este efecto
- Solución: extrae arrays anidados en sub-componentes memoizados con
React.memo
¿Cómo se comprueban las rutas dinámicas de register en TypeScript?
register(`items.${index}.description`); // OK
register(`items.${index}.typo`); // Error de TS- RHF infiere rutas de notación de puntos válidas desde el tipo genérico del formulario
- Los errores tipográficos en nombres de campos anidados se detectan en tiempo de compilación
¿Cómo tipas los campos devueltos por useFieldArray en TypeScript?
- Los campos incluyen un
idgenerado automáticamente más la forma de cada elemento del array - Tipo:
{ id: string; description: string; quantity: number; price: number } - El
ides agregado por RHF y no debe estar en tu esquema de Zod
¿Cómo indica la barra de progreso en el formulario multietapa el paso actual?
- Mapea el array de pasos y renderiza un segmento de barra para cada uno
- Los segmentos en o antes del paso actual obtienen
bg-blue-600; otros obtienenbg-gray-200 - La condición
i <= stepdestaca los pasos completados y actuales
Relacionado
- React Hook Form — conceptos principales de RHF
- Patrones de Formularios Básicos — login, signup, contacto
- Patrones de Formularios Carga de Archivos — validación de carga de archivos
- Visualización de Errores de Formulario — mostrando errores anidados