Form Patterns Complex
Multi-step wizards, dynamic field arrays, and conditional fields — patterns for forms that go beyond the basics.
Recipe
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.
Working Example
"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:
- Multi-step wizard with per-step Zod schemas
- Accumulated data across steps
- Progress indicator
- Back/Next navigation with validation only on the current step
- Merged schema for the final data type
Deep Dive
How It Works
- Multi-step forms split a large schema into per-step schemas, validating only the current step
useFieldArraymanages arrays of objects with stable identity viafield.idappend,remove,insert,move,swap,replace, andupdatemutate the array- Conditional fields use
watchto observe a controlling field and show/hide dependent fields - Per-step schemas are merged at the end to produce the complete validated type
Variations
Conditional 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>
);
});
}TypeScript Notes
// 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 }Gotchas
-
Field array key must be
field.id— Using the array index askeycauses state bugs when items are removed or reordered. Fix: Always usefield.idfromuseFieldArray. -
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
unregisterwhen hiding fields, or filter the data before submission. -
Deep nesting performance — Deeply nested
useFieldArraycan cause excessive re-renders. Fix: Extract nested arrays into memoized sub-components.
Alternatives
| 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 |
FAQs
Why should you use field.id instead of array index as the key in useFieldArray?
- Array index as
keycauses React state bugs when items are removed or reordered field.idis a stable, unique identifier generated byuseFieldArray- Always use
key={field.id}on the mapped elements
How do you validate only the current step in a multi-step form?
- Split the full schema into per-step schemas (e.g.,
Step1Schema,Step2Schema) - Pass only the current step's schema to
zodResolver:resolver: zodResolver(currentStep.schema) - Merge all step schemas at the end for the complete data type
How do you preserve data across steps when the form unmounts between steps?
- Store validated data in parent state:
setCompletedData(prev => ({ ...prev, ...data })) - Pass accumulated data as
defaultValueswhen re-creating the form for each step - Alternatively, use a single form across all steps and show/hide sections
What methods does useFieldArray provide for manipulating arrays?
appendadds an item to the endremove(index)removes an item at a positioninsert,move,swap,replace, andupdateare also available- All methods maintain stable
field.idvalues for existing items
How do you implement conditional fields that show/hide based on another field value?
const type = watch("type");
{type === "business" && <input {...register("ein")} />}- Use
watch("fieldName")to observe the controlling field - Conditionally render dependent fields based on the watched value
What is z.discriminatedUnion and when is it useful for conditional forms?
const Schema = z.discriminatedUnion("type", [
z.object({ type: z.literal("individual"), ssn: z.string() }),
z.object({ type: z.literal("business"), ein: z.string() }),
]);- It validates different shapes based on a shared discriminator field
- Gives better error messages than
z.unionand dispatches in O(1)
Gotcha: What happens to hidden field values when conditional fields are removed from the DOM?
- The value remains in the form data even after the field is hidden
- This can cause unexpected data in the submission payload
- Fix: call
unregister("fieldName")when hiding fields, or filter the data before submission
Gotcha: What causes excessive re-renders with deeply nested useFieldArray?
- Each
useFieldArraysubscribes to changes in its array, triggering re-renders on any mutation - Deeply nested arrays compound this effect
- Fix: extract nested arrays into memoized sub-components with
React.memo
How are dynamic register paths type-checked in TypeScript?
register(`items.${index}.description`); // OK
register(`items.${index}.typo`); // TS error- RHF infers valid dot-notation paths from the form's generic type
- Typos in nested field names are caught at compile time
How do you type the fields returned by useFieldArray in TypeScript?
- Fields include an auto-generated
idplus the shape of each array item - Type:
{ id: string; description: string; quantity: number; price: number } - The
idis added by RHF and should not be in your Zod schema
How does the progress bar in the multi-step form indicate the current step?
- It maps over the steps array and renders a bar segment for each
- Segments at or before the current step get
bg-blue-600; others getbg-gray-200 - The condition
i <= stephighlights completed and current steps
Related
- React Hook Form — core RHF concepts
- Form Patterns Basic — login, signup, contact
- Form Patterns File Upload — file upload validation
- Form Error Display — displaying nested errors