Repeatable groups — invoice line items, emergency contacts, work history, passenger lists — are where form state models that assume a fixed set of named fields break down: rows are added, removed and reordered, and every piece of state keyed by position silently attaches itself to the wrong row.

The symptoms are familiar to anyone who has shipped one. Delete row 2 and the error that belonged to row 3 now sits on the row that moved up into its place. Reorder two rows and the text a user was typing jumps into the other row. Add a row and focus stays on the “Add” button, so a screen-reader user has no idea a new set of fields appeared. This topic sets out the model that avoids all of them — rows with stable identity, state keyed by that identity, validation at two levels, and deliberate focus and announcement on every structural change — and links to the guides that implement each part.

It builds on the flat-state principles in form state fundamentals and architecture, and it is the structural counterpart to cross-field dependency logic: an array rule such as “line totals must not exceed the budget” is a dependency across a variable number of fields.


Problem statement

A repeatable group has three properties that ordinary fields do not:

  1. Cardinality changes at runtime. The set of fields is not known at build time; it grows and shrinks as the user works.
  2. Order may be meaningful. A priority list or itinerary is ordered; a set of tags or contacts may not be. The model must know which.
  3. Rows have identity independent of position. “The contact for Ada” is the same row whether it is first or third.

Most form bugs in repeatable groups come from conflating identity with position. Array indices are the obvious key — items[2].price, errors["items.2.price"], key={index} in React — and they are correct only until the first delete or reorder. After that, every piece of state stored by index describes a different row than it did a moment ago.

The pattern applies whenever users can add or remove instances of a group of fields. It matters most when rows carry their own state beyond values: validation errors, touched flags, pending async checks, expanded or collapsed UI, uploaded files.

Index-keyed state after deleting row 2 Before the delete, row one is Ada with no error, row two is Grace with an invalid phone error, and row three is Alan with a missing email error. After deleting Grace, index-keyed state still has an error at index one, now showing Alan with Grace's invalid phone message, and Alan's missing email error is attached to index two, which no longer exists. With id-keyed state, Grace's error is removed with her row and Alan keeps his own missing email error. Position after delete Row Index-keyed error shown Id-keyed error shown 0 Ada none none 1 Alan "invalid phone" (was Grace's) "email is required" (2) — "email is required" orphaned removed with Grace Index keys are correct only until the first structural change. Every delete, insert-above or reorder reshuffles them.

State machine specification

Each row moves through a small lifecycle, and the array as a whole has its own validity:

Row state Entered by Leaves on
new Add row (empty values, not yet touched) First edit → editing
editing Edit Blur/submit → valid or invalid
valid / invalid Row validation result Edit → editing; remove → removed
removed Remove (kept for undo) Undo → previous state; timeout/submit → gone

The array-level state is derived, not stored: tooFew / tooMany from the row count against min and max, and invalidAggregate from cross-row rules such as duplicates or totals. Two validation levels run independently — row-level for each row’s own fields, array-level for rules about the collection — and their errors render in different places.

A row's lifecycle, including undo A row starts as new when added, with empty values and no errors shown. The first edit moves it to editing. Blur or submit validates it into valid or invalid, and further edits return it to editing. Removing a row moves it to removed, where it is hidden but kept with its values, errors and position so undo can restore it exactly. After the undo window or on submit, removed rows are discarded. new Empty values; no errors shown. Focus moves to its first field and its addition is announced. first edit editing Values change; errors deferred. Reward-early, punish-late timing applies per field. blur / submit valid | invalid Row-level validation result. Errors keyed by row id, never by index. remove removed (undoable) Hidden, values and position kept. Undo restores it exactly; timeout or submit discards it.

Core implementation

The model below is framework-agnostic. Rows carry a client-generated id that never changes; every per-row piece of state is keyed by it. Order lives in one array of ids.

export type RowId = string;

export interface ArrayState<V> {
  order: RowId[];                              // the only place position exists
  rows: Record<RowId, V>;                      // values by identity
  errors: Record<RowId, Record<string, string>>;
  touched: Record<RowId, Record<string, boolean>>;
  removed: { id: RowId; index: number; value: V; at: number }[]; // undo stack
}

const newId = (): RowId => crypto.randomUUID();

export function addRow<V>(s: ArrayState<V>, value: V, at = s.order.length): [ArrayState<V>, RowId] {
  const id = newId();
  const order = [...s.order];
  order.splice(at, 0, id);
  return [{ ...s, order, rows: { ...s.rows, [id]: value } }, id];
}

export function removeRow<V>(s: ArrayState<V>, id: RowId): ArrayState<V> {
  const index = s.order.indexOf(id);
  if (index === -1) return s;
  // Keep the row's value for undo; drop its errors and touched flags with it
  // so nothing can be inherited by the row that moves into its place.
  const { [id]: value, ...rows } = s.rows;
  const { [id]: _e, ...errors } = s.errors;
  const { [id]: _t, ...touched } = s.touched;
  return {
    ...s, rows, errors, touched,
    order: s.order.filter((r) => r !== id),
    removed: [...s.removed, { id, index, value, at: Date.now() }],
  };
}

export function moveRow<V>(s: ArrayState<V>, id: RowId, to: number): ArrayState<V> {
  const order = s.order.filter((r) => r !== id);
  order.splice(Math.max(0, Math.min(to, order.length)), 0, id);
  return { ...s, order };                     // nothing else changes: state is keyed by id
}

// Serialise for submission: positions exist only here, at the edge.
export function toPayload<V>(s: ArrayState<V>): V[] {
  return s.order.map((id) => s.rows[id]);
}

// Map server/schema errors that use indices back onto ids at the edge too.
export function errorsFromIndexed<V>(s: ArrayState<V>, byIndex: Record<number, Record<string, string>>) {
  const out: Record<RowId, Record<string, string>> = {};
  for (const [i, e] of Object.entries(byIndex)) {
    const id = s.order[Number(i)];
    if (id) out[id] = e;
  }
  return out;
}

// Array-level rules run on the ordered values and return ONE message each.
export function arrayErrors<V>(s: ArrayState<V>, rules: { min?: number; max?: number;
  unique?: (v: V) => string }): string[] {
  const values = toPayload(s);
  const msgs: string[] = [];
  if (rules.min !== undefined && values.length < rules.min) msgs.push(`Add at least ${rules.min}.`);
  if (rules.max !== undefined && values.length > rules.max) msgs.push(`Remove ${values.length - rules.max} to continue; the limit is ${rules.max}.`);
  if (rules.unique) {
    const seen = new Set<string>();
    if (values.some((v) => { const k = rules.unique!(v); if (seen.has(k)) return true; seen.add(k); return false; })) {
      msgs.push("Each entry must be different.");
    }
  }
  return msgs;
}

Two rules make this robust. First, positions exist only at the edges — in order, in the submitted payload, and in the translation of index-based errors from schemas and servers. Second, structural operations touch only order except for remove, which deletes the row’s own state so nothing can be inherited.


Integration guidance

Rendering. In React, key={id}; in Vue, :key="id"; in Svelte, {#each order as id (id)}. The key is what tells the framework that a row moved rather than that its contents changed — the full treatment is in stable keys for reorderable field arrays. Field name attributes can still use positions (items[2].price) for native submission, because they are regenerated from order on every render.

Validation. Run row-level validation per row with the same schema used for the item type, and array-level rules on the ordered payload. Schemas such as Zod report item errors with index paths (["items", 2, "price"]); translate them to ids immediately with errorsFromIndexed, as detailed in keeping array errors aligned after reorder and delete. Minimum and maximum counts have their own UX, covered in validating minimum and maximum row counts.

Dirty tracking. Compare by id, not by position: a reordered-but-otherwise-unchanged ordered list is dirty (order changed), while an unordered set that was reordered is not. Use the set/list distinction from deep equality for dirty detection on nested values.

Accessibility. Each row is a fieldset with a legend that includes its position and a meaningful label (“Contact 2: Grace Hopper”). Adding a row moves focus to its first field; removing a row moves focus to a sensible neighbour and announces the removal with an undo option — see undoing row deletion in repeatable groups and keyboard reordering of repeatable rows.

Framework adapters. React Hook Form’s useFieldArray generates its own id per row for exactly this reason (use field.id as the key, never the index); Angular’s FormArray holds control instances whose identity follows the row, covered in dynamic FormArray controls in Angular.

Where position is allowed to exist Incoming data from the server is an ordered array, and each item is given a fresh client id on load. Inside the model, position lives only in the order array, and values, errors, touched flags and pending checks are keyed by id. Rendering derives field names such as items 2 price from the order at render time. The payload is produced by mapping the order back to values, and index-based errors from schemas or the server are translated to ids on arrival. Load Server array. Assign an id to each item. Model order: id[] Everything else keyed by id. Render key = id. name = items[i].x from order. Submit / errors Payload from order. Index errors → ids.

Ordered lists versus unordered collections

Decide early whether the order of rows means something, because several behaviours hang off that one decision. An itinerary, a ranked list of preferences or the steps of a recipe are ordered: moving a row is a real edit, it makes the form dirty, the submitted array must preserve it, and users need a way to reorder by keyboard as well as by drag. A set of email recipients, a list of skills or a group of emergency contacts is usually unordered: the order users happen to add rows in is incidental, reordering controls are clutter, dirty checking should ignore order, and the server may sort the items however it likes.

Mixed cases exist. Invoice line items are unordered in meaning but users expect them to stay where they put them, so treat them as ordered for display and unordered for comparison. Whatever you choose, write it next to the field’s schema so the dirty check, the reorder UI and the server contract all read the same decision rather than each guessing.

The decision also affects validation messages. For ordered lists, row labels should include position (“Stop 3”), and an error about a specific row should name it by position and content. For unordered collections, position is noise; label rows by their content (“Contact: Grace Hopper”) and keep the numbering purely visual.


Edge cases and failure modes

Rows loaded from the server already have ids. Use a separate client id anyway, or reuse the server id only if every row has one. New rows do not have a server id until saved, and mixing “has server id” and “does not” as keys invites collisions and remount bugs.

Duplicate a row. Copy the values into a new row with a new id. Copying the id makes two rows share errors and touched state, and makes the framework treat them as the same element.

Nested arrays. A work history where each job has a list of responsibilities is an array of rows each containing an ArrayState. The same rules apply recursively; the error path translation needs both levels (["jobs", 1, "duties", 3] → [jobId, dutyId]).

Very long lists. Hundreds of rows need virtualisation, and virtualised rows unmount when scrolled away. Because state is keyed by id in the model, not in components, unmounting loses nothing — the approach in virtualizing long fieldsets without losing state.

Autofill into a new row. Browsers can autofill a newly added address row. Reconcile at submit as with any autofilled field.

Drag-and-drop libraries. Many reorder by index callbacks (onDragEnd(from, to)). Translate to ids immediately — moveRow(state, order[from], to) — so the rest of the model never sees indices.


Troubleshooting reference

Symptom Diagnostic step Recovery
Error message appears on the wrong row after a delete Log the error map’s keys; if they are numbers, errors are index-keyed Key errors by row id; translate schema paths on arrival
Typed text jumps to another row after reorder Check the list’s render key; index keys reuse DOM nodes Use the row id as the key
Focus lost after removing a row Check document.activeElement after removal — usually <body> Move focus to the next row’s legend, previous row, or the Add button
“Add at least one” error shows before the user has done anything Array-level rules running on mount Show array errors only after submit or after a row has been removed
Server 422 highlights the wrong line item Compare the payload order with the order at response time Translate index paths using the order that was sent, not the current order

Testing and QA hooks

Give each row a stable test hook tied to its identity, not its position: data-row-id on the row’s fieldset. Tests can then add three rows, delete the second, and assert that the third row’s error is still on the element with the third row’s id. Avoid nth-child selectors in tests for repeatable groups; they encode exactly the index-keyed assumption that produces the bugs.

For accessibility regression tests, assert that after adding a row document.activeElement is inside the new row, that the live region contains the add or remove announcement, and that each row’s legend includes an updated position (“Contact 2 of 3”) after reordering. End-to-end form error tests with Playwright shows the selector strategy in full.

Test cases every repeatable group needs Delete a middle row with errors and assert the following row keeps its own error. Reorder two rows while one has focus and assert typed text stays with its row. Add a row and assert focus is in the new row's first field and the addition was announced. Undo a delete and assert the row returns in its original position with its values. Submit after a reorder that the server rejects and assert the error lands on the row that was sent in that position. Case Action Assert delete middle 3 rows with errors; delete #2 row #3's error still on row #3 (by data-row-id) reorder move row while typing typed text stays with its row add press Add focus in new row; addition announced undo delete, then Undo row restored at its index with values server errors reorder, submit, 422 by index error on the row sent at that index

Common pitfalls

  • Using the index as the React/Vue key. The single most common cause of state jumping between rows.
  • Storing errors and touched flags in an array parallel to values. Parallel arrays must be spliced in lockstep on every operation; one missed splice and they drift.
  • Validating the minimum count on mount. A new form with zero rows is not an error until the user tries to submit.
  • Deleting without undo. Row deletion is destructive and easy to trigger by accident; a short undo window is cheaper than a confirmation dialog.
  • Letting the “Add” button keep focus. Sighted users see the new row; keyboard and screen-reader users need focus moved to it.

Frequently Asked Questions

Can I use the array index if my rows are never reordered?

Only if rows are also never removed from anywhere but the end and never inserted anywhere but the end. Deleting a middle row reshuffles every index after it, which is enough to misattribute errors and focus. Generating an id per row costs one line and removes the whole category of bugs.

Should the client id be sent to the server?

It is harmless and occasionally useful — for example as an idempotency hint for creating child records, or to map server errors by id instead of index. But the server should not depend on it as a permanent identifier; assign real ids on the server when rows are saved.

How do I show an error that belongs to the whole list?

Render array-level errors once, at the top of the group’s fieldset, below its legend, and reference them from the fieldset with aria-describedby. Do not attach them to the first or last row — they are about the collection, and the error summary should link to the group, or to the Add button when the fix is to add a row.

What is the right maximum number of rows?

Whatever the domain requires, enforced both by hiding or disabling the Add action at the limit (with a visible explanation) and by array-level validation for data that arrives by other routes, such as paste or import. Never silently drop rows beyond the limit.


Related

← Form State Fundamentals & Architecture