Form Patterns Complex
Multi-step wizards, dynamic field arrays, and conditional fields - patterns for forms that go beyond the basics.
Busque em todas as páginas da documentação
Multi-step wizards, dynamic field arrays, and conditional fields - patterns for forms that go beyond the basics.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Dynamic field array schema
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>
);
}When to reach for this: When your form has repeating groups, multiple steps, or fields that appear conditionally based on other field values.
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// Multi-step form with per-step schemas
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">
{/* Progress bar */}
<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>
);
}What this demonstrates:
useFieldArray manages arrays of objects with stable identity via field.idappend, remove, insert, move, swap, replace, and update mutate the arraywatch to observe a controlling field and show/hide dependent fieldsConditional fields based on another field:
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>
);
}Nested field arrays (table of tables):
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}>{/* nested fields */}</div>
))}
</div>
);
});
}// useFieldArray fields are typed with an auto-generated id
type FieldWithId = { id: string; description: string; quantity: number; price: number };
// Dynamic register paths are type-safe
register(`items.${index}.description`); // OK
register(`items.${index}.typo`); // TS error
// Discriminated union form typing
type FormData = z.infer<typeof Schema>;
// { type: "individual"; ssn: string } | { type: "business"; ein: string; companyName: string }Field array key must be field.id - Using the array index as key causes state bugs when items are removed or reordered. Fix: Always use field.id from useFieldArray.
Multi-step validation schema mismatch - If you validate with the full schema on each step, untouched future steps will fail. Fix: Validate only the current step's schema.
State lost on step change - If you unmount the form between steps, registered fields lose their values. Fix: Store validated data in a parent state (as shown) or use a single form across all steps.
Conditional field cleanup - When a field is hidden, its value remains in the form data. Fix: Use unregister when hiding fields, or filter the data before submission.
Deep nesting performance - Deeply nested useFieldArray can cause excessive re-renders. Fix: Extract nested arrays into memoized sub-components.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Single long form | Total fields are under 10 and all visible at once | UX research shows users abandon long forms |
| Separate pages per step | Each step is a full server action with saved progress | You want instant back/next without page loads |
| Headless wizard libraries | You need complex step logic (branching, skip, loops) | A simple linear wizard suffices |
| FormData arrays | You use native forms with name="items[]" | You need add/remove/reorder UX |
key causes React state bugs when items are removed or reorderedfield.id is a stable, unique identifier generated by useFieldArraykey={field.id} on the mapped elementsStep1Schema, Step2Schema)zodResolver: resolver: zodResolver(currentStep.schema)setCompletedData(prev => ({ ...prev, ...data }))defaultValues when re-creating the form for each stepappend adds an item to the endremove(index) removes an item at a positioninsert, move, swap, replace, and update are also availablefield.id values for existing itemsconst type = watch("type");
{type === "business" && <input {...register("ein")} />}watch("fieldName") to observe the controlling fieldconst Schema = z.discriminatedUnion("type", [
z.object({ type: z.literal("individual"), ssn: z.string() }),
z.object({ type: z.literal("business"), ein: z.string() }),
]);z.union and dispatches in O(1)unregister("fieldName") when hiding fields, or filter the data before submissionuseFieldArray subscribes to changes in its array, triggering re-renders on any mutationReact.memoregister(`items.${index}.description`); // OK
register(`items.${index}.typo`); // TS errorid plus the shape of each array item{ id: string; description: string; quantity: number; price: number }id is added by RHF and should not be in your Zod schemabg-blue-600; others get bg-gray-200i <= step highlights completed and current stepsRevisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥