Skip to main content
友田 陽大
React forms
React
React Hook Form
Zod
TypeScript
Next.js
フォーム
型安全
フロントエンド

React Hook Form useFieldArray practical guide [latest 2026] — dynamic line items, nested arrays, multi-step wizards

From variable-length line items (billing, quotes) to multi-step wizards, a practical guide to building dynamic forms at production quality with React Hook Form. It explains in real code the correct use of useFieldArray (the field.id key, complete values), nested arrays, Zod's array and cross-row validation, a wizard design that holds all steps in one useForm and partially validates per step with trigger, state persistence, a11y, and testing.

Published
Reading time
12 min read
Author
友田 陽大
Share

Invoice line items, quote items, multiple contacts, and an application's multiple steps — a business system's form isn't done with "fixed input fields." This article, with React Hook Form useFieldArray official and trigger as primary sources, digs into how to build dynamic forms and multi-step wizards at production quality. RHF's basics presuppose the React Hook Form complete guide, and validation the Zod 4 practical guide.

The versions verified (as of June 2026): react-hook-form v7 family, @hookform/resolvers v5 family, zod v4, React 19.


0. The two major forms of dynamic forms

FormExampleToolThe biggest pitfall
Variable-length repetitioninvoice line items, tags, contactsuseFieldArrayrows break with key={index}
Multiple stepsapplication wizard, onboardingone useForm + triggersplitting useForm per step and losing values

These two can also be mixed (a line-item step inside a wizard). For both, the common design principle is to "hold the state in one place and make only the display dynamic."


1. The basics of useFieldArray (3 iron rules)

import { useForm, useFieldArray } from "react-hook-form";

function Demo() {
  const { control, register } = useForm<{ items: { name: string; price: number }[] }>({
    defaultValues: { items: [{ name: "", price: 0 }] },
  });
  const { fields, append, remove, move } = useFieldArray({ control, name: "items" });

  return (
    <>
      {fields.map((field, index) => (
        <div key={field.id}> {/* 鉄則1:key は field.id(index 不可) */}
          <input {...register(`items.${index}.name`)} />
          <input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>削除</button>
        </div>
      ))}
      {/* 鉄則2:append には「完全な値」を渡す(append({}) は不可) */}
      <button type="button" onClick={() => append({ name: "", price: 0 })}>行を追加</button>
    </>
  );
}

The 3 iron rules (derived from the official):

  1. key must be field.id. Using index makes React's diff go wrong on deletion or reordering, causing the accident of another row's value being displayed. field.id is the stable ID RHF assigns.
  2. append / prepend / insert take a complete value. append({}) is not allowed. Pass a value with all properties filled.
  3. register's dot notation. items.0.name is ✅, items[0].name is ❌.

The operation APIs: append / prepend / insert / remove / move (reorder) / swap / update / replace.


2. An invoice line-item form (a B2B SaaS typical)

Add/delete/reorder rows, and the real-time display of the total. For the total, as in the §re-render-optimization principle, push useWatch down to the leaf and avoid the parent's re-render.

"use client";
import { useForm, useFieldArray, useWatch, type Control } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const lineItem = z.object({
  name: z.string().min(1, { error: "品名を入力してください" }),
  qty: z.number({ error: "数量は数値で" }).int().positive({ error: "1以上で" }),
  unitPrice: z.number({ error: "単価は数値で" }).nonnegative(),
});
const invoiceSchema = z.object({
  items: z.array(lineItem).min(1, { error: "明細を1行以上追加してください" }),
});
type Invoice = z.infer<typeof invoiceSchema>;

// 合計だけを購読する末端コンポーネント(親フォームは再描画されない)
function GrandTotal({ control }: { control: Control<Invoice> }) {
  const items = useWatch({ control, name: "items" });
  const total = items.reduce((s, i) => s + (i.qty ?? 0) * (i.unitPrice ?? 0), 0);
  return <output className="text-lg font-bold">{total.toLocaleString()} 円</output>;
}

export function InvoiceForm() {
  const form = useForm<Invoice>({
    resolver: zodResolver(invoiceSchema),
    defaultValues: { items: [{ name: "", qty: 1, unitPrice: 0 }] },
    mode: "onTouched",
  });
  const { register, control, handleSubmit, formState: { errors, isSubmitting } } = form;
  const { fields, append, remove, move } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))} noValidate className="space-y-4">
      <table>
        <thead>
          <tr><th>品名</th><th>数量</th><th>単価</th><th></th></tr>
        </thead>
        <tbody>
          {fields.map((field, index) => (
            <tr key={field.id}>
              <td>
                <input
                  {...register(`items.${index}.name`)}
                  aria-invalid={!!errors.items?.[index]?.name}
                  aria-label={`明細${index + 1}の品名`}
                />
              </td>
              <td>
                <input
                  type="number"
                  {...register(`items.${index}.qty`, { valueAsNumber: true })}
                  aria-label={`明細${index + 1}の数量`}
                />
              </td>
              <td>
                <input
                  type="number"
                  {...register(`items.${index}.unitPrice`, { valueAsNumber: true })}
                  aria-label={`明細${index + 1}の単価`}
                />
              </td>
              <td>
                <button type="button" onClick={() => move(index, index - 1)} disabled={index === 0}>↑</button>
                <button type="button" onClick={() => remove(index)} disabled={fields.length === 1}>削除</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* 配列全体のエラー(最小行数など)は root に出る */}
      {errors.items?.root && <p role="alert">{errors.items.root.message}</p>}
      {typeof errors.items?.message === "string" && <p role="alert">{errors.items.message}</p>}

      <button type="button" onClick={() => append({ name: "", qty: 1, unitPrice: 0 })}>明細を追加</button>
      <div>合計:<GrandTotal control={control} /></div>
      <button disabled={isSubmitting}>{isSubmitting ? "保存中…" : "保存"}</button>
    </form>
  );
}

Make the last row undeletable: guarding remove with disabled={fields.length === 1} prevents "0 line items" on the UI side too (the server side is the final defense with Zod's .min(1)). Multiple defense is the basics of reliability design.

The reason the parent isn't re-rendered even as the total or row count grows, and the virtualization of many line items, see the React Hook Form performance-optimization guide.


3. Nested arrays and cross-row validation (Zod)

A nesting like "further option rows inside a line item" can also be written straightforwardly with dot notation. What's hard is cross-row validation (e.g., the total doesn't exceed the budget, no duplicate item names). With Zod's superRefine, output the error on the relevant row.

const invoiceSchema = z
  .object({
    budget: z.number().nonnegative(),
    items: z.array(lineItem).min(1, { error: "明細を1行以上追加してください" }),
  })
  .superRefine((data, ctx) => {
    // 合計が予算を超えていないか(フォーム全体のエラー)
    const total = data.items.reduce((s, i) => s + i.qty * i.unitPrice, 0);
    if (total > data.budget) {
      ctx.addIssue({
        code: "custom",
        message: `合計 ${total.toLocaleString()} 円が予算を超えています`,
        path: ["items"], // 配列全体に紐づける
      });
    }
    // 品名の重複を、重複した行に出す
    const seen = new Map<string, number>();
    data.items.forEach((item, index) => {
      if (seen.has(item.name)) {
        ctx.addIssue({
          code: "custom",
          message: "品名が重複しています",
          path: ["items", index, "name"], // ✅ その行のそのフィールドへ
        });
      }
      seen.set(item.name, index);
    });
  });

Specifying a path including the array index like path: ["items", index, "name"] lets you read it on the RHF side as errors.items[index].name and flow it straight to the relevant input's aria-invalid. For the design of cross-field validation, also see the Zod 4 practical guide.


4. Multi-step wizard: the correct answer is "one useForm"

The most common failure in a wizard is splitting useForm per step and losing the previous step's values. The correct answer is simple —

Hold the whole form in one useForm and switch only the displayed step. When advancing a step, validate only that step's fields with trigger.

"use client";
import { useState } from "react";
import { useForm, FormProvider, type Path } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

// ステップ定義:各ステップが検証すべきフィールド名を持つ
const STEPS = [
  { id: "account", title: "アカウント", fields: ["email", "password"] },
  { id: "profile", title: "プロフィール", fields: ["name", "company"] },
  { id: "items", title: "明細", fields: ["items"] },
] as const;

export function SignupWizard() {
  const [step, setStep] = useState(0);
  const form = useForm<Wizard>({
    resolver: zodResolver(wizardSchema),
    defaultValues: loadDraft() ?? emptyDraft,
    mode: "onTouched",
    shouldUnregister: false, // 非表示ステップの値を保持する(重要)
  });

  const isLast = step === STEPS.length - 1;

  const next = async () => {
    // 当該ステップのフィールドだけ検証。通れば進む(shouldFocus で最初のエラーへ)
    const ok = await form.trigger(STEPS[step].fields as Path<Wizard>[], { shouldFocus: true });
    if (ok) setStep((s) => Math.min(s + 1, STEPS.length - 1));
  };
  const back = () => setStep((s) => Math.max(s - 1, 0));
  const onSubmit = async (data: Wizard) => {
    await api.signup(data); // 最終送信。resolver が全体を最終検証してから到達する
    clearDraft();
  };

  return (
    <FormProvider {...form}>
      {/* 進捗表示(a11y) */}
      <ol aria-label="進捗">
        {STEPS.map((s, i) => (
          <li key={s.id} aria-current={i === step ? "step" : undefined}>{s.title}</li>
        ))}
      </ol>

      <form onSubmit={form.handleSubmit(onSubmit)} noValidate>
        {/* 値は RHF が保持するので、非表示ステップを unmount しても失われない */}
        {step === 0 && <AccountStep />}
        {step === 1 && <ProfileStep />}
        {step === 2 && <ItemsStep />}

        <nav className="mt-6 flex gap-2">
          {step > 0 && <button type="button" onClick={back}>戻る</button>}
          {!isLast ? (
            <button type="button" onClick={next}>次へ</button>
          ) : (
            <button type="submit" disabled={form.formState.isSubmitting}>送信</button>
          )}
        </nav>
      </form>
    </FormProvider>
  );
}

The design crux:

  • Since shouldUnregister: false (the default) works, even if you unmount a step, the value remains in the RHF state. So values don't disappear between steps.
  • trigger(fields) validates only the specified fields and returns a boolean. shouldFocus: true focuses the first error item.
  • In the final handleSubmit, the resolver finally validates the whole, so an inconsistency that slipped past the step validation can also be rejected at the end (multiple defense).
  • Each step (AccountStep, etc.) takes control/register with useFormContext and just lines up the fields of §1–§3.

5. Reload resistance: persisting the draft

A long wizard is fatal if it disappears on a reload or a mistaken operation. With watch's callback subscription, save the draft and restore it at startup.

import { useEffect } from "react";

const DRAFT_KEY = "signup-wizard-draft";

// 保存:watch のコールバック版はレンダリングを起こさない
function usePersistDraft(form: UseFormReturn<Wizard>) {
  useEffect(() => {
    const sub = form.watch((values) => {
      try {
        localStorage.setItem(DRAFT_KEY, JSON.stringify(values));
      } catch {
        /* ストレージ満杯等は黙ってスキップ(致命ではない) */
      }
    });
    return () => sub.unsubscribe(); // 必ず購読解除
  }, [form]);
}

// 復元:必ず Zod で検証してから初期値に使う(壊れた下書きを信用しない)
function loadDraft(): Wizard | null {
  try {
    const raw = localStorage.getItem(DRAFT_KEY);
    if (!raw) return null;
    const parsed = wizardSchema.partial().safeParse(JSON.parse(raw));
    return parsed.success ? (parsed.data as Wizard) : null;
  } catch {
    return null;
  }
}

Security note: don't save a password or sensitive information in localStorage. Limit the draft target to items painful to re-enter, like name, company name, and line items, and exclude sensitive steps. Making the persistence destination a URL query makes it strong for sharing and back operations, but likewise don't put sensitive information on it.


6. Accessibility

  • Progress: convey the current step to assistive technology with aria-current="step".
  • Focus on error: auto-focus the first error with trigger(..., { shouldFocus: true }) and handleSubmit's shouldFocusError (default true).
  • Row operations: attach an aria-label (e.g., "the unit price of line item 2") to each line-item input, and make the meaning of the add/delete buttons clear. After deletion, move focus to the next valid element.
  • Notify dynamic additions: lightly notifying row additions/deletions in an aria-live="polite" region conveys the change to screen-reader users too.

For the overall picture of form a11y, see the React/Next.js accessibility implementation guide.


7. Testing

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { InvoiceForm } from "./invoice-form";

describe("InvoiceForm", () => {
  it("行を追加・削除できる", async () => {
    render(<InvoiceForm />);
    await userEvent.click(screen.getByRole("button", { name: "明細を追加" }));
    expect(screen.getAllByLabelText(/の品名$/)).toHaveLength(2);
  });

  it("合計が入力に追従する", async () => {
    render(<InvoiceForm />);
    await userEvent.type(screen.getByLabelText("明細1の数量"), "3");
    await userEvent.type(screen.getByLabelText("明細1の単価"), "1000");
    await waitFor(() => expect(screen.getByText(/3,000 円/)).toBeInTheDocument());
  });
});

For a wizard, verify "you can't advance if a step is unfilled" and "you can advance to the end and submit" by user operation. For E2E, see the Playwright E2E practical guide.


8. Anti-patterns and countermeasures

Anti-patternWhat happensCountermeasure
key={index}another row's value appears on deletion/reorderingkey={field.id}
append({}) (partial value)validation/display breaks with undefined fieldspass a complete value
splitting useForm per stepthe previous step's values disappearhold all in one useForm
validating all fields every time in a wizardall errors appear mid-inputtrigger(that step's fields)
hidden steps with shouldUnregister: truevalues disappear / useFieldArray unsupporteduse the default false
outputting cross-row errors only to the wholeyou can't tell which row is badspecify the row with superRefine's path
restoring the draft without validationcrashes with broken datause it after safeParse with Zod

9. FAQ

Q. Is it OK even if line items become hundreds of rows? A. It's OK, but render only the visible rows with React.memo + virtualization (@tanstack/react-virtual). Since values remain in the RHF state, even if you unmount off-screen rows, you get all rows on submit. For details, the performance-optimization guide.

Q. Should I split the schema per step? A. First, one big schema (KISS). For step validation, narrowing the target fields with trigger suffices. For a huge form with highly-independent steps, a design that merges per-step schemas is also possible, but it's enough once you need it (YAGNI).

Q. The total display is one beat slow / heavy. A. Aren't you watching the total directly under useForm? Push useWatch down to the small component that displays it (§2).

Q. Input disappears on the wizard's "back." A. Confirm you didn't set shouldUnregister to true. With the default false, the values of hidden steps are held too.

Q. Where does the whole-array error (minimum row count) appear? A. It appears in errors.items.root?.message or errors.items?.message. Display it near the table with role="alert".


Summary: a dynamic form is "state in one place, display dynamic"

A dynamic form and a wizard aren't scary if you grasp the principles —

  1. Variable-length is useFieldArray. The 3 iron rules of key={field.id}, a complete value, and dot notation.
  2. Aggregations push useWatch to the leaf. Sever the re-render of the whole form.
  3. Cross-row validation, with superRefine's path, outputs the error accurately on the relevant row.
  4. A wizard holds all steps in one useForm and partially validates with trigger. Protect values with shouldUnregister: false.
  5. Restore the draft after validation, and don't persist sensitive information.

These are design for forms that not only "work" but reduce the inputter's burden (mid-save, accurate pointing-out), guarantee data consistency with code, and are easy to maintain. Forms that are the core of a business, like line items, approvals, and applications, are exactly where this build-out pays off.

For complex dynamic forms like billing, quotes, and applications, or the UX/consistency design of a long wizard, please feel free to consult me. The case study below introduces the process of designing and implementing the line-item / approval forms of a B2B SaaS that supports an industry's core operations, with an emphasis on type safety, consistency, and usability.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading