Good to knowReact Performance · 07

Windowing

Render only the visible rows of a 10,000-item list with @tanstack/react-virtual.

On this page · 9 sections
  1. In one minute
  2. The example: the whole catalog
  3. How it works
  4. What you give up
  5. When to use it
  6. Common mistakes
  7. Interview Q&A
  8. Related
  9. Sources

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.

Render everything
Catalog.tsx
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}
What you see
<li> in the DOM: –
Render time (Profiler): –
Visible: rows 0–12
  1. 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.
  2. 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.

0 / 0
The whole catalog, 10,000 products, in one 300 px scrolling list. Timings from React’s <Profiler> (both versions recorded).

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

What you give up

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

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.

Sources

Primary source: tanstack.com/virtual/latest