skip to content

A React component renders a list of rows and needs programmatic access to each row's DOM node. How do you collect those refs, and what has to happen when a row is removed?

level: seniorimportance: should knowfreq 34%

answer

  1. you cannot call a hook per row
  2. one ref, many nodes
  3. a Map keyed by item id
  4. index is not identity
  5. the detach branch must delete

basics

~20 s

Use one callback ref per row, closed over that row's stable id, writing nodes into a Map held in a single useRef. On detach the callback must delete that entry, otherwise the Map retains removed DOM nodes and later lookups return elements no longer in the document.

solid answer

~50 s

You cannot call `useRef` per row, because hooks must run unconditionally and in the same order every render. The working shape is one `useRef` holding a `Map`, plus a callback ref on each row closed over that row's stable id: on attach it does `map.set(id, node)`, on detach it must do `map.delete(id)` — in React 19 by returning a cleanup, in React 18 by branching on the `null` argument. Deleting is the part people skip, and it is what causes the bugs: a `Map` that only ever grows retains detached DOM nodes and their subtrees in memory, and code that iterates it operates on elements no longer in the document. Key the map by the item's identity, never by array index, so that reordering or removing an item does not leave a node filed under someone else's slot.

go deeper

for a junior

Know that you cannot call useRef once per list item, and that the usual answer is one ref holding a collection of nodes filled in by a callback ref on each row.

for a middle

Show the concrete shape: useRef(new Map()), a callback ref closed over the item id that sets on attach and deletes on detach, and lookups by id rather than index.

for a senior

Argue the invariant — the map contains exactly the currently-attached rows — and connect the missing delete to detached-node retention and to imperative calls that silently no-op. Explain why index-based storage breaks under reordering.

for a principal

Weigh whether the codebase should be reaching into row DOM at all: what the imperative surface buys, whether a declarative alternative exists, and how you keep one bespoke ref registry from multiplying across every list in the product.

## Why the obvious approaches fail **One `useRef` per row.** Hooks must be called unconditionally, in the same order, on every render. A list whose length changes cannot call a hook per item, so `items.map(item => useRef(null))` is illegal and the lint rule rejects it. **An array indexed by position.** `refs.current[index] = node` compiles and appears to work, until the list reorders or an item is removed from the middle. Now index 2 holds the node that used to be item C and is currently item D, or a hole where a removed item was. The array's index is not the item's identity, and any code that later looks up "the node for item C" gets the wrong element. ## The shape that works One ref holding a `Map`, keyed by whatever identifies the item in your data, plus a callback ref per row: ```jsx function Rows({ items }) { const nodes = useRef(new Map()); const scrollTo = (id) => nodes.current.get(id)?.scrollIntoView(); return ( <ul> {items.map((item) => ( <li key={item.id} ref={(node) => { nodes.current.set(item.id, node); return () => nodes.current.delete(item.id); }} > {item.label} </li> ))} </ul> ); } ``` The cleanup return is the React 19 form. Targeting React 18, the same callback branches on the argument: ```jsx ref={(node) => { if (node) nodes.current.set(item.id, node); else nodes.current.delete(item.id); }} ``` A `Map` is preferred over a plain object because ids are frequently numbers or non-string values, and because `delete`, `get` and iteration are explicit operations rather than prototype-adjacent property fiddling. ## Why the delete is the whole question An interviewer asking this is usually watching for whether you handle detach at all. **Memory.** A removed `<li>` that is still referenced from your `Map` cannot be garbage collected, and neither can its subtree or any listeners hanging off it. In a long-lived virtualised or paginated list, a map that only grows is a genuine leak that shows up as steadily rising detached-node counts in a heap snapshot. **Correctness.** Code that iterates the map to measure or focus rows will touch nodes that are no longer in the document. Imperative calls against them do nothing visible, which produces the worst kind of bug — silently absent behaviour rather than an error. **Staleness.** If a row unmounts and a *different* row later mounts with the same key, an entry that was never cleaned up may or may not be overwritten depending on ordering. Explicit deletion makes the map's contents exactly the set of currently-attached rows, which is the invariant everything else relies on. If you want the map to hold entries only as long as the nodes are alive regardless, `WeakMap` is the wrong direction here — it is keyed by object, and your keys are ids — so explicit deletion in the cleanup is the mechanism. ## The identity churn caveat The callback in the example above is inline, so it is a new function on every render, and React therefore detaches and re-attaches every row's ref on every render of the list: `delete` then `set`. For a `Map` write that is cheap and self-correcting, and it is the common production shape. If the callback does something more expensive, lift each row into its own component and stabilise the callback there with `useCallback` keyed on the item's id — which is the more maintainable structure for a nontrivial row anyway. ## Where this shows up Roving-focus keyboard navigation across a list, scrolling a selected item into view, driving an imperative animation across items, and anything that needs "give me the element for this record". In all of these the lookup is by record identity, which is the reason the `Map` is keyed by id and the reason the array-by-index version keeps producing bugs that only appear after a reorder. ## What to say out loud Name the three constraints in order: hooks cannot be called per item, so one container ref; nodes must be findable by item identity, so a `Map` keyed by id; and attachment is a pair, so the detach branch must remove the entry. Everything else is detail.

  • Why not just keep an array of nodes indexed by the item's position in the list?
    Because position is not identity. After a reorder or a removal, index 2 refers to a different item, so a lookup for a specific record returns the wrong node, and removals leave holes that iteration has to special-case. Keying by the item's stable id makes the structure survive any list mutation.
  • What symptom would tell you the detach branch is missing?
    Steadily rising detached-DOM-node counts in a heap snapshot for a long-lived list, and imperative calls that quietly do nothing because they landed on an element no longer in the document. Both are silent — nothing throws — which is why the cleanup is the part to verify rather than assume.
  • Does the inline callback ref on each row cause a problem in this pattern?
    It causes a delete-then-set on every render of the list, because the arrow is a new function each time. For a Map write that is cheap and self-correcting. If the row's ref callback does real work, extract the row into its own component and stabilise the callback with useCallback keyed on the item id.

saying these in an interview costs you the question

  • Calls useRef inside the items.map callback
  • Stores nodes in an array indexed by list position
  • Never deletes the entry when a row unmounts
  • Thinks the key prop somehow manages the refs for you
  • Assumes React garbage-collects nodes you still reference

context