ESC

Type to search the knowledge base.

Preserving State on Reorder

Keep local state glued to the right item when lists reorder: stable keys, position traps, and drag-and-drop gotchas.

intermediate3 min read
  • react
  • preserving-state

You drag item C from index 2 to index 0. Every row has an expanded flag in useState. After the drag, the wrong rows look expanded. The list data reordered, but React reused fibers by position because keys were indices — or missing.

Preserving state on reorder means: state follows identity, not array index.

Docs: Rendering Lists, Preserving and Resetting State.

Minimal reproduction

function Row({ item }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button type="button" onClick={() => setOpen((o) => !o)}>
        {item.title}
      </button>
      {open && <p>{item.body}</p>}
    </div>
  );
}

function List({ items }) {
  return items.map((item, i) => (
    // Broken under reorder:
    <Row key={i} item={item} />
  ));
}

After sorting items, position 0 still has the old open state from whoever used to be first. Fix:

items.map((item) => <Row key={item.id} item={item} />)

Now the fiber for item.id moves with the item. Local open travels correctly.

Lifted selection state

If selection is lifted, store ids, not indices:

const [selectedId, setSelectedId] = useState(null);
// ...
const selected = items.find((i) => i.id === selectedId) ?? null;

Index-based selection breaks the moment the array sorts. Same for “checked” sets: Set of ids, not a boolean array aligned to positions.

Controlled reorder UIs

function SortableBoard({ cards, onReorder }) {
  return (
    <ul>
      {cards.map((card, index) => (
        <li key={card.id}>
          <CardBody card={card} />
          <button
            type="button"
            onClick={() => onReorder(move(cards, index, index - 1))}
            aria-label={`Move ${card.title} up`}
          >
            Up
          </button>
        </li>
      ))}
    </ul>
  );
}

The parent owns order (array of ids or ordered entities). Children own ephemeral UI. Keys remain card.id through the entire drag lifecycle — including placeholder rows and overlay portals.

When you want state to reset on move

Rare, but real: a row’s draft should die if the row leaves a “editing zone.” Then change the key or clear state in the drop handler. Do not rely on accidental index keys — make the reset intentional.

// Force remount when lane changes
<Card key={`${card.id}-${card.laneId}`} card={card} />
  • Input caret jumps after sort → index keys.
  • Video player restarts on sibling insert → unstable keys.
  • Accordion open state “jumps” → state at wrong level or wrong keys.

Profile with React DevTools: watch which components remount (state loss) versus update (state kept).

Interview out-loud

“Local state is stored on the fiber. Keys tell reconciliation which fiber is which item. Stable ids preserve state across reorder; index keys leave state stuck to positions. Selection and checked sets should store ids, not indices.”

Further reading

Production checklist

Before you ship a change in this area, walk the list out loud:

  1. What is the source of truth for the data on screen?
  2. What happens on remount, route change, and Strict Mode double-invoke?
  3. Which updates are urgent (input) versus deferrable (filter large lists)?
  4. Did you profile before adding memo, context splits, or virtualization?
  5. Is there an accessibility path: keyboard, focus, names, and errors?

If you cannot answer those, the API knowledge will not save the interview or the incident.

Related guides