In one minute
A component re-renders when its state changes, a context it reads changes, or its parent re-renders. The last one is usually harmless: React compares the output and changes nothing in the DOM. It matters when it’s multiplied, like a list of 500 rows that all re-render when you hover one. memo on the row skips renders when its props are equal, but one prop that changes for every row (like highlightedIndex) defeats it. Two fixes: a custom comparator that checks only what the row shows, or, simpler and preferred, pass each row primitive props it can compare directly (isHighlighted). And first: fix slow renders before unnecessary ones.
You’ll be able to: use memo on list items, explain why it fails on a shared changing prop, and fix it with a comparator or derived primitive props.
1function ProductList() {2 const [highlightedIndex, setHighlightedIndex] = useState(-1)3 return products.map((p, i) => (4 <ListItem key={p.id} product={p} index={i}5 highlightedIndex={highlightedIndex}6 onHover={setHighlightedIndex} />7 ))8}- event
onHover={setHighlightedIndex} />The mouse moves to row 6:setHighlightedIndex(6). - render phase
const [highlightedIndex, setHighlightedIndex] = useState(-1)The list re-renders, so every row does.
1const ListItem = memo(Row)23function ProductList() {4 const [highlightedIndex, setHighlightedIndex] = useState(-1)5 return products.map((p, i) => (6 <ListItem key={p.id} product={p} index={i}7 highlightedIndex={highlightedIndex}8 onHover={setHighlightedIndex} />9 ))10}const ListItem = memo(Row)For an unrelated update (“Refresh”),memoworks: every prop is the same. Recorded: 0 row renders.- event
onHover={setHighlightedIndex} />The mouse moves to row 6. - render phase
highlightedIndex={highlightedIndex}But every row receiveshighlightedIndex, which changed from 5 to 6, so every row fails the comparison.
1const ListItem = memo(Row, (prev, next) =>2 (prev.index === prev.highlightedIndex) ===3 (next.index === next.highlightedIndex))45function ProductList() {6 const [highlightedIndex, setHighlightedIndex] = useState(-1)7 return products.map((p, i) => (8 <ListItem key={p.id} product={p} index={i}9 highlightedIndex={highlightedIndex}10 onHover={setHighlightedIndex} />11 ))12}- event
onHover={setHighlightedIndex} />The mouse moves to row 6. - render phase
(prev.index === prev.highlightedIndex) ===The comparator asks what the row shows: “was I highlighted, am I now?” Only rows 5 and 6 change their answer.
1const ListItem = memo(Row)23function ProductList() {4 const [highlightedIndex, setHighlightedIndex] = useState(-1)5 return products.map((p, i) => (6 <ListItem key={p.id} product={p} index={i}7 isHighlighted={i === highlightedIndex}8 onHover={setHighlightedIndex} />9 ))10}- event
onHover={setHighlightedIndex} />The mouse moves to row 6. - render phase
isHighlighted={i === highlightedIndex}The comparison moves into the parent: each row gets a boolean, and only two booleans changed. Plainmemodoes the rest, with no custom code to keep in sync.
The example: hovering a 500-product list
Hovering a row highlights it; clicking selects it; “Refresh” re-renders the list for an unrelated reason. Each row counts its renders, and the recorder reads the count after each action.
function ListItem({ product, index, highlightedIndex, selectedId, onHover, onSelect }: ItemProps) {
itemRenders++
const isHighlighted = index === highlightedIndex
const isSelected = product.id === selectedId
return (
<li
data-index={index}
className={`${isHighlighted ? 'highlighted' : ''} ${isSelected ? 'selected' : ''}`}
onMouseEnter={() => onHover(index)}
onClick={() => onSelect(product.id)}
>
{product.name}
</li>
)
}
function ProductList({ Item }: { Item: typeof ListItem }) {
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [, setRefresh] = useState(0)
return (
<>
<button id="refresh" onClick={() => setRefresh((n) => n + 1)}>Refresh</button>
<ul className="list">
{products.map((p, i) => (
<Item key={p.id} product={p} index={i} highlightedIndex={highlightedIndex} selectedId={selectedId} onHover={setHighlightedIndex} onSelect={setSelectedId} />
))}
</ul>
</>
)
}
1. Plain rows
{
"mount": "ListItem renders: 500",
"refresh": "ListItem renders: 500",
"hover row 5": "ListItem renders: 500",
"hover row 6": "ListItem renders: 500",
"highlighted": [
"6"
]
}
Every action rendered all 500 rows, even though a hover only changes two of them.
2. memo
const MemoItem = memo(ListItem)
{
"mount": "ListItem renders: 500",
"refresh": "ListItem renders: 0",
"hover row 5": "ListItem renders: 500",
"hover row 6": "ListItem renders: 500",
"highlighted": [
"6"
]
}
“Refresh” now renders nothing: every prop is the same as last time. But hovering still renders all 500: each row receives highlightedIndex, which changed for every row, though only two rows look different.
3. A custom comparator
const ComparedItem = memo(ListItem, (prev, next) => {
// re-render only if something this row shows has changed
if (prev.product !== next.product || prev.index !== next.index) return false
const wasHighlighted = prev.index === prev.highlightedIndex
const isHighlighted = next.index === next.highlightedIndex
const wasSelected = prev.product.id === prev.selectedId
const isSelected = next.product.id === next.selectedId
return wasHighlighted === isHighlighted && wasSelected === isSelected
})
{
"mount": "ListItem renders: 500",
"refresh": "ListItem renders: 0",
"hover row 5": "ListItem renders: 1",
"hover row 6": "ListItem renders: 2",
"highlighted": [
"6"
]
}
The comparator answers “would this row look different?”. Hovering row 5 (from nothing) re-rendered 1 row; moving to row 6 re-rendered 2. But it duplicates the row’s logic: if ListItem starts showing something new and the comparator isn’t updated, the row silently shows stale data.
4. Primitive props
const PrimitiveItem = memo(function PrimitiveItem({ product, index, isHighlighted, isSelected, onHover, onSelect }: {
product: Product
index: number
isHighlighted: boolean
isSelected: boolean
onHover: (index: number) => void
onSelect: (id: string) => void
}) {
itemRenders++
return (
<li
data-index={index}
className={`${isHighlighted ? 'highlighted' : ''} ${isSelected ? 'selected' : ''}`}
onMouseEnter={() => onHover(index)}
onClick={() => onSelect(product.id)}
>
{product.name}
</li>
)
})
{products.map((p, i) => (
<PrimitiveItem
key={p.id}
product={p}
index={i}
isHighlighted={i === highlightedIndex}
isSelected={p.id === selectedId}
onHover={setHighlightedIndex}
onSelect={setSelectedId}
/>
))}
{
"mount": "ListItem renders: 500",
"refresh": "ListItem renders: 0",
"hover row 5": "ListItem renders: 1",
"hover row 6": "ListItem renders: 2",
"highlighted": [
"6"
]
}
Same counts as the comparator, with plain memo. The parent computes isHighlighted for each row, so each row gets a boolean that only changes when its own highlight does. This is what react.dev recommends over custom comparators.
How it works
- Render, reconcile, commit. A re-render calls the component (render), compares its new elements with the old ones (reconciliation), and touches the DOM only for differences (commit). An “unnecessary” re-render does the first two and commits nothing (Render and Commit).
memoskips render and reconciliation for that subtree, but the parent still runs its.mapand creates 500 elements to compare. For thousands of rows, see Windowing.- The comparator returns
trueto skip (props “equal”), the opposite of the old classshouldComponentUpdate. - Fix slow renders first. A render that’s slow every time it runs is still slow when it does have to run;
memoonly reduces how often.
Common mistakes
- A shared value as a prop of every row (
highlightedIndex,selectedId): every row’s props change together. Derive per-row primitives in the parent. - Inline callbacks per row (
onClick={() => select(p.id)}): new functions every render. Pass a stable setter and the id, as above. - A comparator that ignores a prop the row uses. Stale UI with no warning.
memoon cheap components with coarse updates. It’s overhead when the whole screen changes anyway.
Interview Q&A
What are the reasons a component re-renders?
Its state changes, a context it reads changes, or its parent re-renders (props changing is part of that last case). By default every child of a re-rendering parent re-renders. Recorded: “Refresh” rendered all 500 rows.
Why didn't memo help with hover?
Every row received highlightedIndex, which changed, so every row’s props differed. Recorded: 500 renders per hover with memo.
What's the second argument to memo?
A comparator (prevProps, nextProps) => boolean; true means “equal, skip”. Recorded: comparing only “was/is highlighted” brought a hover down to 1–2 renders.
Why prefer primitive props over a custom comparator?
The comparison moves into the parent’s data (isHighlighted={i === highlightedIndex}), so plain memo works and there’s no comparator to keep in sync with the component. Recorded: the same 1–2 renders.
Slow render or unnecessary re-render: which first?
The slow render. Skipping it sometimes still leaves it slow every time it runs.
Related
- Element Optimization: how
memocompares props. - Windowing: when there are too many rows even with
memo. - React Re-rendering: what triggers renders.
Sources
- Kent C. Dodds: Fix the slow render before you fix the re-render
- react.dev:
memo(including “Specifying a custom comparison function”) - Topic order inspired by Kent C. Dodds’ EpicReact React Performance workshop; the example app and code here are this handbook’s own.
Primary source: kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render