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.
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
- Generate ids when rows enter the client. Wrap server data with
withRowIdsat load, before it reaches any component, so every row has an id from the first render. - 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. - Key the rendered row by the id.
key,:key, or Svelte’s keyed each. Put the same id on adata-row-idattribute for tests and for mapping DOM events back to rows. - Keep using indices for
nameand labels. Positions are fine in anything that is recomputed on every render —nameattributes, “Contact 2 of 3” legends. They are not fine in anything that persists across renders. - Strip ids before submitting. The payload is built from the ordered rows without
_rowId, unless your API deliberately accepts it. - 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.
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.
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.