skip to content

A React Hook Form signup form renders useFieldArray phone rows with key={index}, and after removing a middle row, row-level state sticks to the wrong numbers — why, and what is the fix?

level: seniorimportance: should knowfreq 45%

answer

  1. React keeps state by key
  2. which row survived the removal
  3. fields carry a generated id
  4. rows must be objects

basics

~20 s

React keeps component state and DOM nodes by key, so with index keys a removal makes the surviving rows inherit the removed row's state. useFieldArray gives every row a stable field.id; use key={field.id}, as the documentation requires.

solid answer

~40 s

`useFieldArray({ control, name: 'phones' })` returns `fields`, each entry carrying a generated `id`, plus `append`, `remove`, `insert`, `move` and friends. With `key={index}`, `remove(1)` on rows A, B, C renders keys 0 and 1: React concludes key 2 was removed and keeps the first two instances, so the instance that belonged to B now renders C's data. Anything held with that instance — a row's local `useState` (say, a country-code picker), focus, a widget's internal buffer, the uncontrolled DOM node that RHF must re-sync — belongs to the wrong phone. With `key={field.id}`, React unmounts exactly B. Related rules from the docs: each entry must be an object (`{ number: '' }`), not a bare string; `append` needs a complete object, not `{}`; do not stack `append` and `remove` in one handler; avoid `shouldUnregister: true`.

code

tsx · 45 lines
tsx
import { useState } from 'react';
import { useFieldArray, useForm, type UseFormRegister } from 'react-hook-form';

type Signup = { email: string; phones: { number: string }[] };

function PhoneRow({ index, register, onRemove }: {
  index: number;
  register: UseFormRegister<Signup>;
  onRemove: () => void;
}) {
  const [country, setCountry] = useState('+44'); // row-local state follows the React key
  return (
    <div>
      <select value={country} onChange={(e) => setCountry(e.target.value)}>
        <option>+44</option>
        <option>+33</option>
        <option>+49</option>
      </select>
      <input {...register(`phones.${index}.number` as const, { required: true })} />
      <button type="button" onClick={onRemove}>Remove</button>
    </div>
  );
}

export function PhoneList() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<Signup>({
    defaultValues: { email: '', phones: [{ number: '' }] },
  });
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'phones',
    rules: { minLength: { value: 1, message: 'Add at least one phone number' } },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <PhoneRow key={field.id} index={index} register={register} onRemove={() => remove(index)} />
      ))}
      {errors.phones?.root && <p role="alert">{errors.phones.root.message}</p>}
      <button type="button" onClick={() => append({ number: '' })}>Add phone</button>
      <button>Sign up</button>
    </form>
  );
}

go deeper

for a junior

Remember the rule: key rows by field.id, never by index, and give append a full object.

for a middle

Explain how key-based reconciliation keeps the wrong row instance after a removal, and what state travels with that instance.

for a senior

Diagnose it from a bug report, reproduce with a middle-row removal, and audit move and insert along with remove.

for a principal

Decide how shared form components enforce stable keys and object-shaped rows so this class of bug cannot recur.

## The symptom A signup form lets users add several phone numbers. Each row is a small `PhoneRow` component with its own local state — a country-code picker held in `useState` — plus a registered input for the number. The list is rendered with index keys: ```tsx {fields.map((field, index) => ( <PhoneRow key={index} index={index} register={register} onRemove={() => remove(index)} /> ))} ``` With three rows — A (UK), B (France), C (Germany) — the user removes B. The form's data is now A and C, but C's number is displayed next to **France**. Nothing throws; the form is quietly wrong, and what is submitted no longer matches what the user chose. ## Why it happens Two mechanisms combine: 1. **React reconciles lists by key.** After `remove(1)`, the list renders two rows with keys `0` and `1`. React compares them with the previous keys `0`, `1`, `2` and concludes that the row with key `2` was removed. It **keeps the instances** for keys `0` and `1` — their state, their DOM nodes, their focus — and unmounts the third. 2. **Row identity lives in that instance.** The instance at key `1` was B's; it now renders C's data but still holds B's local state. For registered inputs, which are **uncontrolled**, React also never pushes a `value` into the reused DOM node; React Hook Form has to re-sync it, and the reused nodes are exactly the ones that can drift. So index keys make React delete the wrong instance, and everything attached to an instance ends up on the wrong phone number. ## The fix: field.id `useFieldArray` generates a stable identifier for every row and exposes it as `field.id` in `fields` (the `keyName` option that renamed it is deprecated). The documentation's first rule for the hook is that `field.id`, not the index, must be the key: ```tsx {fields.map((field, index) => ( <PhoneRow key={field.id} index={index} register={register} onRemove={() => remove(index)} /> ))} ``` Now removing B unmounts exactly B's instance; A and C keep their own state and nodes, and only the `index` prop — and therefore the register name `phones.${index}.number` — is recomputed for C. ## The other field-array rules | Rule | Why | |---|---| | Entries must be objects: `[{ number: '' }]` | Flat arrays such as `['555-0100']` are not supported | | `append`, `prepend`, `insert`, `update` need complete objects | `append()` and `append({})` are invalid; supply every input's default | | Do not stack actions in one handler | e.g. `append` then `remove` in one click; run the second after a render | | One `useFieldArray` per `name` | Each instance has its own state; two for one name conflict | | Avoid `shouldUnregister: true` | Rows mount and unmount during operations, so values get dropped | | `update(index, obj)` remounts the row | Use `setValue` to change one field without remounting | ## Validating the array itself `useFieldArray` accepts `rules` (since 7.34.0) for the array as a whole — `required`, `minLength`, `maxLength`, `validate` — with the error reported at `errors.phones?.root`. That is the right place for "at least one phone number", while each row's own rules stay on its `register` call. These array rules apply to built-in validation; with a resolver, the schema expresses the same constraint. ## Diagnosing it in a real codebase - Search the render for `key={index}` or `key={i}` on any `fields.map`. - Reproduce by removing or moving a **middle** row; removing the last row hides the bug, because the surviving keys still point at the same rows. - Look for anything a row holds outside the form's values: local `useState`, refs, third-party widgets with internal buffers, focus. Those are what visibly stick to the wrong row. - After fixing the key, test `move`, `swap` and `insert` too; they break the same way with index keys.

  • Why does removing the last phone row not reveal the index-key bug in a React Hook Form field array?
    When the last row goes, keys 0 to n-2 still belong to the same rows as before, so React happens to unmount the right instance. The bug shows only when a middle row is removed, or rows are inserted, moved or swapped, because then index keys point at different rows than they did on the previous render.
  • In React Hook Form, how do you require at least one phone row?
    Pass `rules: { minLength: 1 }` (or `{ value: 1, message }`) to `useFieldArray`. The error is reported at `errors.phones.root`, separate from each row's own errors, and these array rules apply to built-in validation only; with a resolver, express the constraint in the schema instead.
  • Why should you not call append and then remove in the same click handler of a React Hook Form field array?
    The documentation advises against stacking field-array actions in one handler and shows the alternative: perform the second action after the next render, for example `remove(0)` inside an effect, or restructure so that a single action such as `replace` or `update` does the job.

saying these in an interview costs you the question

  • key={index} is fine because the values live in React Hook Form's state
  • Field arrays can hold plain strings like ['555-0100', '555-0199']
  • append() with no argument adds an empty row
  • Removing the last row proves the list keys are correct
  • A row's local state is reset whenever its data changes