Virtualize long lists
Render only visible rows plus a small overscan, placed with translateY.
From frontend-patterns, section “Virtualization for Long Lists”.
Same list, two DOM sizes
Press Mount, then Restyle and Scroll test.
Every row mountedBad
Not mounted
- Rows in DOM
- 0
- Elements
- 0
- Mount to paint
- –
- Restyle
- –
- Slowest scroll frame
- –
Virtual, overscan 5Good
Not mounted
- Rows in DOM
- 0
- Elements
- 0
- Mount to paint
- –
- Restyle
- –
- Slowest scroll frame
- –
import { useVirtualizer } from '@tanstack/react-virtual'
export function VirtualMarketList({ markets }: { markets: Market[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated row height
overscan: 5 // Extra items to render
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
</div>
</div>
)
}
Overscan hides the blank edge
Press Fling down, then try overscan 4 and 20.
overscan: 0Bad
- Rows in DOM
- 0
- Largest blank
- –
- Frames with a gap
- –
overscan: 5Good
- Rows in DOM
- 0
- Largest blank
- –
- Frames with a gap
- –
useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 5, // rows kept mounted past each edge: 5 × 56 = 280px of runway
})
The estimate is the scrollbar
Move the estimate off the real height, then Jump to end.
Slot height = estimateBad
- Spacer height
- –
- Each card vs slot
- –
Measured rowsGood
- Spacer height
- –
- Rows measured
- –
- Thumb jumps
- –
const virtualizer = useVirtualizer({
count: markets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 140,
overscan: 5,
})
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement} // no fixed height: the row reports its own
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<MarketCard market={markets[virtualRow.index]} />
</div>
))}
Why it matters, pros and cons
Why it matters
Each mounted row costs memory, layout and paint. Virtualize to pay for the view.
Pros
- DOM size stays flat as data grows.
- Mount time ignores data size.
- Restyles touch dozens of rows, not thousands.
Cons
- Find in page misses unmounted rows.
- Variable heights need measuring; bad estimates jump.
- Under a few hundred rows, paginate instead.