In one minute
Some lists are slow even when every row is cheap and memoized: there are simply too many of them. React creates and compares an element per row, and the browser styles, lays out and paints a DOM node per row. Windowing (or virtualization) renders only the rows in view, plus a few extra (overscan), inside a tall spacer that gives the scrollbar the full list’s height. As you scroll, rows leaving the window unmount and new ones mount. With @tanstack/react-virtual, you give useVirtualizer the row count, the scroll container and a size estimate, and render what getVirtualItems() returns.
You’ll be able to: virtualize a long list, explain the spacer-and-offset layout, and name what you give up.
1function ListAll() {2 return (3 <div className="scroller">4 <ul>5 {products.map(p => <li key={p.id}>{p.name}</li>)}6 </ul>7 </div>8 )9}- render phase
{products.map(p => <li key={p.id}>{p.name}</li>)}React creates 10,000 elements and 10,000 DOM nodes, though only 13 fit in the box. - render phase
{products.map(p => <li key={p.id}>{p.name}</li>)}Every re-render of the list (the “Refresh” button) repeats the work for all 10,000 rows.
1function ListVirtual() {2 const scrollerRef = useRef(null)3 const virtualizer = useVirtualizer({4 count: products.length,5 getScrollElement: () => scrollerRef.current,6 estimateSize: () => 24,7 overscan: 5,8 })9 return (10 <div ref={scrollerRef} className="scroller">11 <ul style={{ height: virtualizer.getTotalSize() }}>12 {virtualizer.getVirtualItems().map(row => (13 <li key={row.key}14 style={{ transform: `translateY(${row.start}px)` }}>15 {products[row.index].name}16 </li>17 ))}18 </ul>19 </div>20 )21}- render phase
const virtualizer = useVirtualizer({From the scroll position, the box height and the row size, the virtualizer works out which rows are visible, plus 5 extra. - render phase
<ul style={{ height: virtualizer.getTotalSize() }}>A tall inner element (10,000 × 24 px) keeps the scrollbar the same as with the full list. - commit phase
{virtualizer.getVirtualItems().map(row => (Only those rows are rendered. Recorded: 18 rows in the DOM, mounted in about 2 ms. - event
style={{ transform: `translateY(${row.start}px)` }}>Scroll to row 5,000: rows that leave the window unmount, new ones mount, each placed withtranslateY. Recorded: 23 rows in the DOM, and the same first visible product as the full list.
The example: the whole catalog
The catalog page lists all 10,000 products in a 300 px box. React’s <Profiler> reports how long rendering the list took:
<Profiler id="list" onRender={(_id, phase, actualDuration) => log(`${phase}: React spent ${Math.round(actualDuration)} ms rendering the list`)}>
<List />
</Profiler>
1. Render everything
function ListAll() {
return (
<div className="scroller">
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
)
}
{
"mount": [
"mount: React spent 75 ms rendering the list"
],
"rowsInDom": 10000,
"refresh": [
"update: React spent 73 ms rendering the list"
],
"afterScroll": {
"rowsInDom": 10000,
"firstVisible": "Velvet Kettle 35"
}
}
10,000 rows in the DOM, about 13 visible, and every re-render (the “Refresh” button) redoes all of them.
2. Virtualized
function ListVirtual() {
const scrollerRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => scrollerRef.current,
estimateSize: () => 24, // px per row
overscan: 5, // extra rows above and below the visible ones
})
return (
<div ref={scrollerRef} className="scroller">
<ul style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((row) => (
<li
key={row.key}
data-index={row.index}
style={{ position: 'absolute', top: 0, width: '100%', height: row.size, transform: `translateY(${row.start}px)` }}
>
{products[row.index].name}
</li>
))}
</ul>
</div>
)
}
{
"mount": [
"mount: React spent 2 ms rendering the list",
"nested-update: React spent 1 ms rendering the list"
],
"rowsInDom": 18,
"refresh": [
"update: React spent 0 ms rendering the list"
],
"afterScroll": {
"rowsInDom": 23,
"firstVisible": "Velvet Kettle 35"
}
}
18 rows in the DOM at the top (13 visible + 5 overscan below), 23 after scrolling to row 5,000 (overscan on both sides). The first visible product after the scroll was the same in both versions, so the scroll position and order are exact. (nested-update is the virtualizer measuring the container after mount and rendering once more.)
How it works
- The math: from
scrollTop, the container height and each row’s size, the virtualizer computes the first and last visible indexes.getTotalSize()is the full height (10,000 × 24 px), set on an inner element so the scrollbar is right. - Rows are absolutely positioned at
translateY(row.start).transformdoesn’t trigger layout for the other rows and is cheap to animate. - It re-renders on scroll, but only when the visible range changes, and only those ~20 rows.
- Variable heights: pass a better
estimateSizeand let it measure real rows withmeasureElement.
What you give up
- In-page find (⌘F / Ctrl+F) can’t find rows that aren’t in the DOM.
- Screen readers see only the rendered rows. Use
aria-setsizeandaria-posinsetif the total matters. - Keyboard navigation to an off-screen item needs
scrollToIndexbefore focusing. - Scroll restoration and anchor links need extra work.
When to use it
Measure. A common rule of thumb: consider it when a view renders several hundred repeated rows, the DOM grows past roughly 1,500 nodes, or scrolling visibly janks. Below that, Optimize Rendering (memoized rows) is usually enough, and pagination or “load more” can be simpler still.
Common mistakes
- Forgetting the spacer height or the
position: relativecontainer. Rows overlap or the scrollbar is wrong. - Keying rows by index when the list can reorder: use
row.keyfrom the virtualizer, or the item’s id. - A container without a fixed height or
overflow: auto. Nothing scrolls, so everything is “visible”.
Interview Q&A
What is windowing?
Rendering only the visible part of a long list (plus a little overscan), with a spacer that keeps the full scroll height, and swapping rows in and out as the user scrolls. Recorded: 18 rows in the DOM instead of 10,000.
Why is a long list slow even if each row is memoized?
memo skips calling each row, but the parent still creates and React still compares an element per row, and the browser still lays out every DOM node. Recorded: rendering all 10,000 rows took tens of milliseconds on every update; the virtualized list, a few.
What does useVirtualizer need?
count, getScrollElement (the scrolling element) and estimateSize (a row’s size). You render getVirtualItems(), positioned with each item’s start, inside an element of height getTotalSize().
What's overscan?
Extra rows rendered just outside the viewport so fast scrolling doesn’t show blank space before React catches up.
What do you lose?
Browser find-in-page, full screen-reader context, simple keyboard navigation to off-screen items, and easy scroll restoration.
Related
- Optimize Rendering: memoize rows first.
- Concurrent Rendering: keep input responsive while a big list renders.
Sources
- TanStack Virtual (
@tanstack/react-virtual) - web.dev: Virtualize large lists, Avoid an excessive DOM size
- Topic order inspired by Kent C. Dodds’ EpicReact React Performance workshop; the example app and code here are this handbook’s own.
Primary source: tanstack.com/virtual/latest