A React list renders each row with onClick={() => onSelect(item.id)}, creating one closure per row on every render. What are the alternatives that avoid that, and what do you trade for them?
answer
- one closure per row per render
- matters only at a memo boundary
- let the child bind its own id
- one shared handler, id on the element
- attributes hand back strings
basics
~20 sPush the binding into the row component so the parent passes one stable callback plus the id, or render a single shared handler and read the id from a data attribute on the clicked element. Both trade convenience for a stable function identity.
solid answer
~50 sThere are two real alternatives. The first, and the one I would usually reach for, is to move the closure into the row: the parent passes a single stable `onSelect` callback plus the item, and the row calls `onSelect(item.id)` itself. Each row still creates a closure, but it belongs to that row's own render, so a memoized row keeps identical props when the parent re-renders. The second is one handler on the container or on every row, with the id carried in a `data-id` attribute and read back from `event.currentTarget.dataset.id`. That gives a genuinely single shared function, at the cost of losing the object reference — the value comes back as a string, so you re-parse it and look the item up again, and you give up type safety. For lists of ordinary size, leaving the inline closure alone is a perfectly defensible answer.
code
javascript · 20 linesimport { memo, useCallback } from 'react';
const Row = memo(function Row({ item, onSelect }) {
return <button onClick={() => onSelect(item.id)}>{item.name}</button>;
});
function List({ items, select }) {
const onSelect = useCallback(id => select(id), [select]);
return (
<ul>
{items.map(item => (
<li key={item.id}>
<Row item={item} onSelect={onSelect} />
</li>
))}
</ul>
);
}
export default List;go deeper
Know that the inline arrow lets you pass the row's id to the handler and that this is the normal, acceptable way to write a list — you are not expected to optimise it away.
Explain both alternatives concretely: moving the closure into a memoized row so the parent passes one stable callback, and a single handler reading the id from a data attribute, with the string-conversion trade that implies.
Show that you pick based on what is actually under pressure — memo boundaries and list size — and that you can state why hooks cannot supply per-item stability inside a loop.
Own the pattern choice across a codebase: which list components get memoized rows at all, how identifiers are typed once they leave the closure, and how you keep the optimisation from spreading into lists that never needed it.
## What the per-row closure actually costs ```jsx {items.map(item => ( <li key={item.id}> <button onClick={() => onSelect(item.id)}>{item.name}</button> </li> ))} ``` Every render of the list allocates one closure per row. For a hundred rows that is a hundred short-lived function objects, which on its own is not a problem worth solving — the elements, the props objects and the strings around them cost more. The cost that can matter is identity. If `Row` is wrapped in `React.memo` so that unrelated parent state (a search box, a hover flag) does not re-render every row, the fresh `onClick` prop defeats the comparison on every row, every time. The memo does no work except comparing. That is the situation where the question is worth asking at all. ## Option 1: let the row bind the argument The parent stops constructing the per-item closure and hands down two stable things: the item it already passes, and one callback whose identity does not change. ```jsx const Row = memo(function Row({ item, onSelect }) { return <button onClick={() => onSelect(item.id)}>{item.name}</button>; }); function List({ items, select }) { const onSelect = useCallback(id => select(id), [select]); return items.map(item => <Row key={item.id} item={item} onSelect={onSelect} />); } ``` The closure still exists — it now lives inside `Row` and is recreated whenever `Row` renders, which is precisely when it should be. From the memo comparison's point of view, `Row` receives the same `item` object and the same `onSelect` function, so a parent re-render bails out. This is the idiomatic React answer because it keeps the typed object reference, keeps the code readable, and puts the memoization boundary where the render actually is. Its requirement is that `onSelect` really is stable: if the parent recreates `select` every render, wrapping it in `useCallback` with `[select]` stabilises nothing. ## Option 2: one handler plus a data attribute The alternative is to give up per-row functions entirely. One handler, defined once, reads the identifier off the element that was clicked: ```jsx function List({ items, select }) { const onClick = useCallback(e => select(e.currentTarget.dataset.id), [select]); return items.map(item => ( <button key={item.id} data-id={item.id} onClick={onClick}>{item.name}</button> )); } ``` Now every row shares one function object, so identity is stable by construction with no `useCallback` per row and no closure allocation at all. What you trade: - **Everything becomes a string.** Attribute values are text, so a numeric id arrives as `"42"` and needs converting, and a composite key needs encoding and decoding. - **You lose the reference.** The closure could capture the whole `item`; the attribute can only carry an identifier, so the handler has to look the item back up in the source array. - **Type safety evaporates.** In TypeScript the closure form checks the argument at compile time; a value pulled out of a dataset is `string | undefined` and any structure you claim about it is an assertion. - **You must be careful which element you read from.** Reading from the element the handler is attached to is well defined; reading from whatever was actually clicked is not, because a click can land on an inner span. ## Choosing between them Ask what is really under pressure. If the list is a normal page of rows and nothing is memoized, do nothing: the inline closure is the clearest code and its cost is noise. If rows are memoized and re-render on unrelated parent state, push the binding into the row — you keep types and readability and you fix the comparison. Reserve the data-attribute form for genuinely large or hot lists where you want a single handler shared across thousands of elements and are willing to accept string identifiers. The wrong answer is to reach straight for `useCallback` inside the `map` callback — hooks cannot be called in a loop at all, so the reflex does not even compile as a rule-abiding component. That constraint alone is a useful thing to say out loud: per-item stability has to come from the child's own render or from sharing one handler, never from memoizing inside the loop.
- Why can you not just call useCallback inside the map callback to stabilise each row's handler?Because hooks must be called unconditionally in the same order on every render of a component, and a loop over data of changing length breaks that. Per-item stability has to come from somewhere else: the row component's own render, which is a separate component instance with its own hook list, or a single shared handler on the parent.
- With the data-attribute approach, why read the id from the element the handler is attached to rather than from whatever was clicked?Because a click can land on a nested element — an icon or a span inside the button — and that inner node carries no data-id. Reading from the element that owns the handler gives you the row you attached to, deterministically. Otherwise you have to walk up the tree to find the nearest element carrying the attribute.
- Is the per-row closure ever worth eliminating on allocation grounds alone?Practically never. Closure allocation is cheap and short-lived, and the surrounding element and props objects cost more. Eliminate it when a memo comparison depends on the handler's identity, or when you are rendering an unusually large list and want a single shared function — not because allocations feel wasteful.
saying these in an interview costs you the question
- Calls useCallback inside the map callback to stabilise each row
- Assumes the per-row closure is a real performance cost
- Forgets that a data attribute returns a string, not a number
- Thinks passing the whole item object is impossible without a closure
- Reads the id from the clicked node instead of the handler's element