Rendering repeatable rows with the array index as the key tells the framework that “row 2” is the same element before and after a delete — so it keeps row 2’s DOM node, its uncontrolled input value, its focus and its component state, and gives them to whichever row now sits at position 2.

Dynamic field arrays and repeatable groups sets out the identity-over-position model. The render key is where that model meets the framework’s reconciler. Get it wrong and everything else — id-keyed errors, undo, reorder — is undermined by the DOM itself holding position-keyed state.


Context and prerequisites

Every virtual-DOM and compiled framework reconciles lists by key. When the list changes, it matches old and new children with the same key, reuses their DOM nodes and component instances, and only creates or destroys the unmatched ones. The key therefore is the element’s identity as far as the framework is concerned.

With key={index} the keys after deleting the second of three rows are 0, 1 — the framework sees “row 2 was removed” (the last one), not “row 1 was removed”. It destroys the last DOM node and patches the props of the remaining two. Anything not driven by props survives on the wrong row:

  • the current value of an uncontrolled input (the DOM owns it),
  • focus and text selection,
  • component-local state such as “expanded”, a pending async check, or a date picker’s open state,
  • CSS transitions and animation state.
Deleting the middle row with index keys and with id keys With index keys, the framework destroys the last DOM node and patches the first two, so the node that held Grace's half-typed uncontrolled value now shows Alan's props but keeps Grace's typed text and focus. With id keys, the framework destroys Grace's node and leaves Ada's and Alan's nodes untouched, so every value and focus stays with its row. Aspect key = index key = row id Node destroyed the last one (Alan's) Grace's Alan's row shows Grace's node, patched with Alan's props Alan's own node, untouched Uncontrolled typed text Grace's text in Alan's row stays with its row Focus stays at position 2 (now Alan) lost only if Grace's row had it

The core pattern: assign ids once, at the edges

export type WithId<T> = T & { _rowId: string };

// Assign on the way IN: server data and defaults get ids once, at load.
export function withRowIds<T extends object>(items: T[]): WithId<T>[] {
  return items.map((item) => ({ ...item, _rowId: crypto.randomUUID() }));
}

// Strip on the way OUT: the server never needs the client row id.
export function withoutRowIds<T extends object>(items: WithId<T>[]): T[] {
  return items.map(({ _rowId, ...rest }) => rest as unknown as T);
}

// New rows and duplicates always get a FRESH id.
export const blankRow = <T extends object>(defaults: T): WithId<T> =>
  ({ ...defaults, _rowId: crypto.randomUUID() });

export const duplicateRow = <T extends object>(row: WithId<T>): WithId<T> =>
  ({ ...row, _rowId: crypto.randomUUID() });
// React
{rows.map((row, i) => (
  <fieldset key={row._rowId} data-row-id={row._rowId}>
    <legend>Contact {i + 1} of {rows.length}</legend>
    {/* name uses the index: fine, it is recomputed on every render */}
    <input name={`contacts[${i}].name`} defaultValue={row.name} />
  </fieldset>
))}
<!-- Vue -->
<fieldset v-for="(row, i) in rows" :key="row._rowId" :data-row-id="row._rowId">…</fieldset>

<!-- Svelte -->
{#each rows as row, i (row._rowId)}<fieldset data-row-id={row._rowId}>…</fieldset>{/each}

Step-by-step walkthrough

  1. Generate ids when rows enter the client. Wrap server data with withRowIds at load, before it reaches any component, so every row has an id from the first render.
  2. Give every new row a fresh id. Blank rows and duplicated rows both get crypto.randomUUID(); copying an existing id makes two rows share one identity.
  3. Key the rendered row by the id. key, :key, or Svelte’s keyed each. Put the same id on a data-row-id attribute for tests and for mapping DOM events back to rows.
  4. Keep using indices for name and labels. Positions are fine in anything that is recomputed on every render — name attributes, “Contact 2 of 3” legends. They are not fine in anything that persists across renders.
  5. Strip ids before submitting. The payload is built from the ordered rows without _rowId, unless your API deliberately accepts it.
  6. Translate library callbacks immediately. Drag-and-drop and table libraries report (fromIndex, toIndex); convert to ids on the spot, as in keeping array errors aligned after reorder and delete.

Why the name attribute can still use positions

It can seem inconsistent to key rows by id while naming their inputs contacts[2].name. The difference is lifetime. A key persists across renders — it is how the framework decides which element is which from one render to the next — so it must follow the row. A name is read only at the moment the form is serialised: FormData walks the DOM in document order and collects each name and value as they are now. Because names are recomputed from the current order on every render, the submitted payload is always in the order the user sees. Using ids in names (contacts[3f2a…].name) would work too, but most server frameworks expect bracketed indices for arrays, and the ids would have to be stripped on the server. Positions in names, ids in keys: each is used where its lifetime fits.

What the key must not be The array index shifts on delete, insert and reorder. A value field such as email changes as the user types, remounting the row on every keystroke and losing focus, and may not be unique. A random value generated during render changes every render and remounts every row every time. A server id alone is missing for new unsaved rows. A client id assigned once when the row enters the client avoids all four. Index Shifts on delete, insert and reorder. A value (email, name) Changes as the user types; remounts and drops focus. Random at render New key every render; every row remounts. Server id alone New rows have none until saved.

Failure modes and edge cases

1. Keys generated during render

key={crypto.randomUUID()} or key={Math.random()} inside the map produces a new key every render, so every row remounts on every keystroke: focus is lost after each character. Ids must be created once and stored with the row.

2. Using a field value as the key

key={row.email} looks stable until the user edits the email — every keystroke changes the key, remounting the row and dropping focus. It also collides when two rows temporarily hold the same value (two blank rows).

3. Server ids plus client ids

When loaded rows have server ids and new rows do not, key={row.id ?? row.tempId} works only if a row keeps the same key after it is saved. If saving replaces tempId with the new server id, the key changes and the row remounts mid-edit. Keep the client id for the row’s lifetime on screen.

4. React Hook Form’s field.id

useFieldArray returns fields each with a generated id. Use field.id as the key, never the index, and do not overwrite it with your own id property — the library’s keyName option exists to avoid that collision.

5. Hydration

Ids generated with crypto.randomUUID() during server rendering differ from those generated on the client, causing a hydration mismatch on data-row-id. Either generate ids only on the client after hydration or serialise the server-generated ids into the page along with the data, as discussed in hydration sync for SSR forms.

Typing during a delete, with index keys The user has focus in row three and has typed half an email into an uncontrolled input. A remove action deletes row two. With index keys the framework keeps the DOM nodes at positions zero and one and destroys position two. Row three's data moves to position one, but the DOM node at position one is row two's old node, so the user's half-typed email is gone from view and focus is lost. With id keys, row three's node is untouched and the typed text and focus remain. User Framework DOM typing in row 3 (uncontrolled) remove row 2 index keys: destroy node at position 2 row 3's text and focus gone id keys: destroy row 2's node only

Verification checklist


Frequently Asked Questions

Is key={index} ever acceptable?

Only for lists that are never reordered, never have items removed or inserted except at the end, and whose rows hold no state outside props. Repeatable form groups almost never meet all three, because deleting a row is the whole point.

Does crypto.randomUUID work everywhere?

It is available in all current browsers in secure contexts (HTTPS and localhost) and in Node 19+ globally. For older environments or insecure origins, a counter (row-${++n}) is enough — ids need to be unique within the page, not globally.

Should the key be on the fieldset or on a wrapper component?

On whatever element the list map returns directly. If each row is a <ContactRow> component, put the key on it; the component’s internal state then follows the row too.


Related

← Dynamic Field Arrays and Repeatable Groups