React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

formsmulti-stepwizarddynamic-fieldsfield-arrays

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
  • useFieldArray manages arrays of objects with stable identity via field.id
  • append, remove, insert, move, swap, replace, and update mutate the array
  • Conditional fields use watch to 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 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.

Alternatives

AlternativeUse WhenDon't Use When
Single long formTotal fields are under 10 and all visible at onceUX research shows users abandon long forms
Separate pages per stepEach step is a full server action with saved progressYou want instant back/next without page loads
Headless wizard librariesYou need complex step logic (branching, skip, loops)A simple linear wizard suffices
FormData arraysYou 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 key causes React state bugs when items are removed or reordered
  • field.id is a stable, unique identifier generated by useFieldArray
  • 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 defaultValues when 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?
  • append adds an item to the end
  • remove(index) removes an item at a position
  • insert, move, swap, replace, and update are also available
  • All methods maintain stable field.id values 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.union and 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 useFieldArray subscribes 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 id plus the shape of each array item
  • Type: { id: string; description: string; quantity: number; price: number }
  • The id is 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 get bg-gray-200
  • The condition i <= step highlights completed and current steps