Interview: Selectable List with 800 Items - My Solution & Reasoning
Event delegation, Set state, memo, and useCallback - how I balanced performance and readability in a classic React interview task with 800 list items.
Task type: Live coding interview - React performance
List size: 800 items (5 sizes × 10 fruits × 16 colors)
Links: Original task · My solution
The Problem
You're given a React component that renders a large flat list of colored items (800 total). The task:
- Clicking an item selects or unselects it
- Multiple items can be selected at a time
- Avoid unnecessary re-renders of list items (performance is explicit requirement)
- Selected items must be visually highlighted
- Selected items' names must be shown at the top of the page
The original starter code — nothing is implemented, just a plain list render with no state:
const List = ({ items }) => (
<Fragment>
<ul className="List">
{items.map(item => (
<li key={item.name} className={`List__item List__item--${item.color}`}>
{item.name}
</li>
))}
</ul>
</Fragment>
);Approaches I Considered
Before writing a single line I thought through 2–3 different ways to solve this, each with its own trade-offs.
Approach 1: State as an array + per-item onClick
The most straightforward idea: store selected items as an array, attach an individual onClick handler to each <li>.
// Simple, but two problems:
// 1. Array includes() is O(n) - slow at scale
// 2. Every render creates a new onClick function per item → breaks memo
const [selected, setSelected] = useState([]);
items.map(item => (
<li onClick={() => toggle(item.name)}> ... </li>
))This is readable but kills the performance requirement — 800 new arrow functions on every state change, and memo can't help because the callback reference always changes.
Approach 2: State as a Set + individual handlers + useCallback per item
Upgrade the data structure to Set (O(1) lookup) and memoize each handler. But useCallback per item in a map is tricky — hooks can't be called conditionally or inside loops in a clean way, and you'd need a stable reference per item.name. Gets messy fast.
Approach 3 (chosen): Event delegation + Set state + single useCallback
Instead of attaching 800 listeners, attach one click handler to the <ul> and read the target item from a data-name attribute. This is the classic DOM event delegation pattern — and it maps perfectly onto React.
This gave me the best balance: clean code, minimal overhead, and memo actually works.
My Solution — Full Code
const { Fragment, memo, useState, useCallback } = React;
const List = ({ items }) => {
const [selectedItems, setSelected] = useState(new Set());
const onClickHandler = useCallback((e) => {
const targetName = e.target.closest('li').dataset.name;
if (!targetName) return;
setSelected(prev => {
const next = new Set(prev);
if (next.has(targetName)) {
next.delete(targetName);
} else {
next.add(targetName);
}
return next;
});
}, []);
return (
<Fragment>
Selected items: {Array.from(selectedItems).join(', ')}
<ul className="List" onClick={onClickHandler}>
{items.map((item) => (
<ListItem
key={item.name}
item={item}
selected={selectedItems.has(item.name)}
/>
))}
</ul>
</Fragment>
);
};
const ListItem = memo(({ item, selected }) => (
<li
className={`List__item List__item--${item.color} ${selected ? "selected" : ""}`}
data-name={item.name}
>
{item.name}
</li>
));Decision Breakdown — Why Each Choice
useState(new Set()) — not an array
The selection state needs two operations: check membership and toggle. With an array both are O(n). With a Set both are O(1). At 800 items it doesn't feel slow either way, but it's the right data structure for the job — and it signals to the interviewer that you think about complexity.
On update I always create a new Set (new Set(prev)) rather than mutating the existing one. React requires immutable state updates to detect changes, so this is not optional.
Event delegation on <ul> — not per <li>
This is the core performance decision. Instead of passing an onClick to each of the 800 <ListItem> components, I put a single handler on the parent <ul>. When any <li> is clicked the event bubbles up and I identify the target via e.target.closest('li').dataset.name.
Why this matters for performance: the onClickHandler never needs to change (it has no dependencies), so useCallback with empty deps [] keeps the same reference across all re-renders. The <ul> never passes a new prop to children — the handler just doesn't go to children at all.
useCallback with empty deps []
The handler references only setSelected, which is stable by React's guarantee. So deps can genuinely be [] — no cheating, no eslint suppression needed. This means the same function reference is reused for the lifetime of the component.
memo on ListItem
With event delegation in place, ListItem receives exactly two props: item (stable object reference from the parent's props, never changes) and selected (a boolean derived from selectedItems.has(item.name)).
When you click one item, only two booleans change: the previously selected item flips to false, the newly selected item flips to true. React re-renders List, but memo short-circuits all 798 other ListItem renders — only the 2 affected items re-render.
The memo dependency question — what I thought about
While working on ListItem I considered whether I needed a custom comparator for memo:
// Default memo — shallow comparison of all props
const ListItem = memo(({ item, selected }) => (
<li ...>{item.name}</li>
));
// Custom comparator — I could have written this:
const ListItem = memo(
({ item, selected }) => (
<li ...>{item.name}</li>
),
(prev, next) => prev.selected === next.selected && prev.item === next.item
);I decided the default shallow comparison is enough here because:
itemis always the same object reference (comes from the staticitemsarray, never recreated)selectedis a primitive boolean — shallow compare works perfectly on primitives
A custom comparator would be identical in behavior but add noise. The default memo does exactly what I need.
data-name attribute on <li>
This is what connects the delegation handler to the item identity. e.target.closest('li') safely handles clicks on child elements (text nodes, nested spans, etc.), and .dataset.name gives the item name without any closure or reference to the items array inside the handler.
The Trade-off I Accepted
Event delegation means ListItem no longer owns its own click behavior — the interaction logic lives in the parent. For a more complex real-world component (e.g. items with buttons, links, or nested interactables) this would get awkward. In that case I'd switch to passing a stable onToggle callback via useCallback and accept that each item gets its own handler.
For this problem — simple clickable tiles, single action — delegation is the cleaner and faster choice.
Performance Summary
Key Takeaways
Event delegation is underused in React. Most developers reach for individual onClick handlers by habit. Putting the handler on the container and reading from data-* attributes is a pattern borrowed from vanilla JS that works perfectly in React and makes memo trivially effective.
Set is the right structure for selection state. It's semantically correct (a selection is a set, not a sequence), O(1) for all operations, and signals data-structure awareness in an interview context.
memo without stable props is worthless. The memoization only delivers value when combined with the delegation approach — they're designed together, not independently.