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.
Busca en todas las páginas de la documentación
Asistentes multietapa, arrays de campos dinámicos y campos condicionales - patrones para formularios que van más allá de lo básico.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
useFieldArray gestiona arrays de objetos con identidad estable a través de field.idappend, remove, insert, move, swap, replace y update mutan el arraywatch para observar un campo controlador y mostrar/ocultar campos dependientesCampos 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>
);
});
}// 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 }La clave del array de campos debe ser field.id - Usar el índice del array como key causa errores de estado cuando se eliminan o reordenan elementos. Solución: Siempre usa field.id de useFieldArray.
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 unregister cuando ocultes campos, o filtra los datos antes del envío.
Rendimiento de anidación profunda - useFieldArray profundamente anidado puede causar re-renderizados excesivos. Solución: Extrae arrays anidados en sub-componentes memoizados.
| 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 |
key causa errores de estado de React cuando se eliminan o reordenan elementosfield.id es un identificador único y estable generado por useFieldArraykey={field.id} en los elementos mapeadosStep1Schema, Step2Schema)zodResolver: resolver: zodResolver(currentStep.schema)setCompletedData(prev => ({ ...prev, ...data }))defaultValues cuando recreas el formulario para cada pasoappend agrega un elemento al finalremove(index) elimina un elemento en una posicióninsert, move, swap, replace y update también están disponiblesfield.id estables para elementos existentesconst type = watch("type");
{type === "business" && <input {...register("ein")} />}watch("fieldName") para observar el campo controladorconst Schema = z.discriminatedUnion("type", [
z.object({ type: z.literal("individual"), ssn: z.string() }),
z.object({ type: z.literal("business"), ein: z.string() }),
]);z.union y envía en O(1)unregister("fieldName") cuando ocultes campos, o filtra los datos antes del envíouseFieldArray se suscribe a cambios en su array, activando re-renderizados en cualquier mutaciónReact.memoregister(`items.${index}.description`); // OK
register(`items.${index}.typo`); // Error de TSid generado automáticamente más la forma de cada elemento del array{ id: string; description: string; quantity: number; price: number }id es agregado por RHF y no debe estar en tu esquema de Zodbg-blue-600; otros obtienen bg-gray-200i <= step destaca los pasos completados y actualesRevisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥