Validating Dynamic Field Arrays in React Hook Form

Dynamic rows added through React Hook Form’s useFieldArray break naive per-field wiring because each row’s inputs share a name prefix and mount at different times, so this page shows how to validate an arbitrary-length array of rows while still routing the outcome through a single native Constraint Validation API pass.

When to Use This useFieldArray Validation Approach

Reach for this pattern when the number of inputs is not known at design time — invoice line items, multiple email addresses, a variable list of team members — and each row carries the same shape of fields. If your form is a fixed set of named inputs, you do not need useFieldArray at all; wire the fields directly as shown in React Hook Form Validation.

  • Use this when rows are added and removed at runtime and each needs independent per-field errors.
  • Use the Zod resolver alongside it when array-level rules (min length, uniqueness across rows) belong in one schema.
  • Use React Hook Form Async Field Validation when a row value must be checked against the server before submit.
Field array validation flow Row state feeds per-row validation rules which feed one native reportValidity pass. One array, N rows, one validity pass useFieldArray tracks fields[] + keys Per-row rules register(`rows.${i}.x`) reportValidity() one native pass
Each dynamic row registers under an indexed name, and a single reportValidity call surfaces the first invalid row.

Minimal useFieldArray + reportValidity() Implementation

The baseline ships <form novalidate> and drives the final gate through one native reportValidity() call. React Hook Form owns the per-field rules; the native API owns the user-facing summary and focus.

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

type Row = { email: string; quantity: number };
type FormValues = { rows: Row[] };

export function LineItemsForm() {
  const formRef = useRef<HTMLFormElement>(null);

  const {
    register,
    control,
    handleSubmit,
    setError,
    formState: { errors },
  } = useForm<FormValues>({
    defaultValues: { rows: [{ email: "", quantity: 1 }] },
    // Validate on submit; the native gate runs once, on the button press.
    mode: "onSubmit",
  });

  const { fields, append, remove } = useFieldArray({ control, name: "rows" });

  // handleSubmit only calls this when RHF's own rules pass.
  const onValid = (values: FormValues) => {
    // Array-level rule RHF cannot express per-field: at least one row.
    if (values.rows.length === 0) {
      setError("rows", { type: "min", message: "Add at least one line item." });
      return;
    }
    console.log("submit", values);
  };

  return (
    <form
      ref={formRef}
      noValidate
      onSubmit={(e) => {
        // Single manual native pass: shows the first browser bubble + focuses it.
        if (!formRef.current?.reportValidity()) {
          e.preventDefault();
          return;
        }
        void handleSubmit(onValid)(e);
      }}
    >
      {fields.map((field, index) => (
        // field.id is a stable key across reorders — never use the array index.
        <fieldset key={field.id}>
          <input
            type="email"
            placeholder="Email"
            aria-invalid={errors.rows?.[index]?.email ? "true" : "false"}
            {...register(`rows.${index}.email`, {
              required: "Email is required",
              pattern: { value: /^[^@\s]+@[^@\s]+$/, message: "Invalid email" },
            })}
          />
          <input
            type="number"
            min={1}
            placeholder="Qty"
            aria-invalid={errors.rows?.[index]?.quantity ? "true" : "false"}
            {...register(`rows.${index}.quantity`, {
              required: "Quantity is required",
              min: { value: 1, message: "Must be at least 1" },
              valueAsNumber: true,
            })}
          />
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </fieldset>
      ))}

      <button type="button" onClick={() => append({ email: "", quantity: 1 })}>
        Add row
      </button>
      <button type="submit">Save</button>
    </form>
  );
}

Because type="email", required, and min are native attributes, reportValidity() alone catches the common cases and moves focus to the first offending input. React Hook Form’s rules layer on richer messages and array-level checks the native API cannot express. For the messages the browser shows, see custom validity messages.

useFieldArray Option Reference

Option Type Default Purpose
name string Dot-path to the array in form values, e.g. "rows"; every input registers as `${name}.${index}.field`.
control Control The control object from useForm, connecting the array to the form store.
keyName string "id" Property added to each field for React keys; use field.id, never the map index.
rules RegisterOptions Array-level validation (required, minLength, maxLength, validate) applied to the whole array.
shouldUnregister boolean false When true, removed rows drop their values and errors from the store immediately.
mode (on useForm) "onSubmit" | "onBlur" | "onChange" "onSubmit" When per-field rules re-run; keep "onSubmit" to align with a single native gate.

Verification Steps

import { test, expect } from "@playwright/test";

test("empty row in the middle blocks submit and focuses it", async ({ page }) => {
  await page.goto("/line-items");
  await page.getByRole("button", { name: "Add row" }).click();
  await page.getByRole("button", { name: "Add row" }).click();

  const emails = page.getByPlaceholder("Email");
  await emails.nth(0).fill("a@example.com");
  await emails.nth(2).fill("c@example.com");
  // Row index 1 left blank.

  await page.getByRole("button", { name: "Save" }).click();
  await expect(emails.nth(1)).toBeFocused();
});

Edge Cases & Failure Modes

Index used as the React key. Using key={index} instead of key={field.id} makes React reuse DOM nodes when a row is removed, so error state and cursor position leak to the wrong row. Always spread the field object and key on its stable field.id.

Errors orphaned after remove(). By default React Hook Form retains a removed row’s error under its old index, leaving formState.errors.rows sparse and misaligned. Set shouldUnregister: true on useFieldArray, or call clearErrors("rows") before re-validating, so array-level cross-field validation reads a clean array.

Async row checks racing submit. If a row is verified against the server, an in-flight asynchronous server checks request can resolve after the row is removed and write an error to a stale index. Guard each row’s async validator with the current fields list, and prefer resolving these before the native reportValidity() gate rather than inside it.

Enforcing Cross-Row Uniqueness Inside register

Uniqueness is the classic rule that a single input cannot see: whether a row’s email is a duplicate depends on the other rows, not on the value in isolation. When you have not adopted a resolver and want the check to stay inline, register’s validate callback receives the whole form via the formValues argument that React Hook Form passes as the second parameter, so you can compare the current row against its siblings without lifting state.

// Attach to the email input of each row. RHF invokes `validate` with the
// field's own value first and the entire form snapshot second.
{...register(`rows.${index}.email`, {
  required: "Email is required",
  validate: {
    unique: (value: string, formValues: FormValues) => {
      const normalized = value.trim().toLowerCase();
      if (!normalized) return true; // let `required` own the empty case
      // Count how many rows resolve to the same address.
      const matches = formValues.rows.filter(
        (row) => row.email.trim().toLowerCase() === normalized,
      );
      return matches.length <= 1 || "This email is already used in another row";
    },
  },
})}

Two subtleties make this reliable. First, normalize before comparing — Ada@x.com and ada@x.com are the same mailbox, and skipping the fold ships a false pass. Second, because every row’s validator inspects the same array, editing row one to collide with row three leaves row three still reading as valid until it re-runs; set mode: "onChange" or call trigger("rows") after an edit so both ends of a duplicate light up together. When the rule set grows past a single field, that re-validation cost is the signal to move the whole check into the Zod resolver, where one pass sees the array and reports each offending index once.

Keeping Long Arrays Fast with Controller Isolation

Field arrays degrade quietly. Every keystroke in an uncontrolled register input is cheap, but the moment you switch a row to a controlled component, or read watch("rows") at the form root, a single edit re-renders every mounted row. On a ten-row invoice that is invisible; on a two-hundred-row import grid it drops frames. The fix is to scope reactivity to the row that actually changed rather than the array as a whole.

import { useWatch, Controller, useFormContext } from "react-hook-form";

// Reads ONLY this row's quantity, so a change here re-renders this row alone.
function RowSubtotal({ index }: { index: number }) {
  const { control } = useFormContext<FormValues>();
  const qty = useWatch({ control, name: `rows.${index}.quantity` });
  return <output>{Number.isFinite(qty) ? qty : 0} units</output>;
}

useWatch subscribes to a single dot-path instead of the array root, and Controller confines controlled-input churn to one row’s subtree. Pair either with React.memo on the row component keyed by field.id so untouched rows bail out of reconciliation entirely. Reserve the root-level watch("rows") for genuinely array-wide derived values — a running total, a “rows are unique” banner — and never for per-row display, where it turns an O(1) edit into an O(n) render. Measuring this is straightforward: wrap the row in the React Profiler and confirm that editing row forty commits only row forty.

Frequently Asked Questions

Why do my field array inputs lose their values when I remove a row?

You are almost certainly keying the mapped rows on the array index rather than field.id. React reuses the DOM node at that index, so removing row two makes row three inherit row two's rendered state. Spread the field object and set key={field.id}.

How do I require at least one row in a useFieldArray?

Array-length rules are not per-field, so pass them to the array itself via the rules option: useFieldArray({ control, name: "rows", rules: { required: true, minLength: 1 } }). The resulting error appears at formState.errors.rows.root, not on any individual input.

Should array validation live in Zod or in register rules?

Put cross-row rules such as uniqueness or minimum length in Zod schema validation through the resolver, since a single schema sees the whole array at once. Reserve inline register rules for simple per-input constraints like required and pattern.

Does watch on a field array re-render every row?

Reading watch("rows") at the form root subscribes the whole component to any change in any row, so one keystroke commits every mounted row. For per-row derived values use useWatch({ control, name: `rows.${index}.field` }) inside a memoized row component instead — it subscribes to a single path and re-renders only that row.

How do I validate nested field arrays, like items within each group?

Call useFieldArray again inside the row component with the nested path, e.g. name={`groups.${groupIndex}.items`}, passing the same control. Each inner register name becomes groups.${g}.items.${i}.field, and errors nest to match. The single reportValidity() gate still catches the first invalid native input anywhere in the tree, so no extra wiring is needed at the leaf level.

← Back to React Hook Form Validation