How would you structure the form’s state for adding/removing dynamic fields
Store dynamic fields as an array of objects, each with a STABLE unique id (not the index). Add = append, remove = filter by id, update = map by id. The id is the React key and the lookup handle — using the index breaks values/focus on removal. A form library's useFieldArray does exactly this.
Dynamic fields ("add another phone number") are an array in state — and the entire correctness of it hinges on stable ids.
The structure
const [fields, setFields] = useState([
{ id: crypto.randomUUID(), value: "" },
]);Each entry is an object with:
- a stable, unique
id— generated once when the row is created, never derived from the index; - the actual field data (
value, or nested values for a multi-input row).
The three operations — all keyed by id
// ADD — append a new row
const add = () =>
setFields((f) => [...f, { id: crypto.randomUUID(), value: "" }]);
// REMOVE — filter out by id
const remove = (id) =>
setFields((f) => f.filter((row) => row.id !== id));
// UPDATE — map, replace the matching row immutably
const update = (id, value) =>
setFields((f) => f.map((row) => (row.id === id ? { ...row, value } : row)));And render with the id as the React key:
fields.map((row) => (
<input key={row.id} value={row.value}
onChange={(e) => update(row.id, e.target.value)} />
))Why the id is non-negotiable
If you use the array index as the key (or to identify rows), removing a middle row shifts every subsequent index. React then reconciles wrong: the value, focus, and validation error of row 3 suddenly appear on row 2. With a stable id, React knows exactly which row left and the rest are untouched. This is the bug this question is testing.
Scaling up
- Validation/errors — keep them keyed by the same id (
errors[row.id]), not by index. - Complex rows — each row object holds nested fields;
updatespreads the row. - Use a form library — React Hook Form's
useFieldArrayis purpose-built for this and hands you a stablefield.idto use as the key. For anything beyond trivial, don't hand-roll it.
The framing
"Dynamic fields are an array of objects in state, each with a stable unique id generated when the row is created — never the array index. Add appends, remove filters by id, update maps by id, all immutably. The id is both the React key and the lookup handle. The reason it must be a real id: using the index means removing a middle row shifts indices and React misattributes values, focus, and errors to the wrong rows. In practice I'd use useFieldArray, which gives me exactly this with a stable field id."
Follow-up questions
- •Why does using the array index as the key break on removal?
- •How do you keep validation errors associated with the right dynamic row?
- •How does useFieldArray help here?
- •How would you handle reordering dynamic fields?
Common mistakes
- •Using the array index as the key or row identifier.
- •Mutating the array with push/splice instead of immutable updates.
- •Generating a new id on every render instead of once at creation.
- •Keying errors by index so they desync after a removal.
Performance considerations
- •Immutable map/filter rebuild the array each change — fine for typical form sizes. For very large dynamic lists, memoize rows and stabilize callbacks so unchanged rows don't re-render.
Edge cases
- •Removing the first or a middle row.
- •Reordering rows (drag to sort).
- •Removing all rows — handle the empty state.
- •Rows with nested sub-fields.
Real-world examples
- •Invoice line items, multiple addresses/phone numbers, survey question builders.
- •React Hook Form useFieldArray powering repeatable form sections.