Must knowReact Performance · 06

Optimize Rendering

memo on 500 list rows, why one changing prop defeats it, and fixing that with a comparator or primitive props.

On this page · 7 sections
  1. In one minute
  2. The example: hovering a 500-product list
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

Plain
ProductList.tsx
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}
What you see
Rows 4–7: Desk Lamp 1 · Bluetooth … (row 5 highlighted)
ListItem renders on this hover: –
  1. eventonHover={setHighlightedIndex} />The mouse moves to row 6: setHighlightedIndex(6).
  2. render phaseconst [highlightedIndex, setHighlightedIndex] = useState(-1)The list re-renders, so every row does.

0 / 0
A list of 500 products. Hovering highlights a row. The mouse moves from row 5 to row 6 (all four versions recorded).

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

Common mistakes

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.

Sources

Primary source: kentcdodds.com/blog/fix-the-slow-render-before-you-fix-the-re-render