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:
- Cardinality changes at runtime. The set of fields is not known at build time; it grows and shrinks as the user works.
- 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.
- 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.
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.
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.
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.
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.