1. Why Huge Lists Are Slow — Independent of React
Rendering 20,000 <div> elements is slow even with no JavaScript
framework involved at all — it's a fundamental browser cost, not a React-specific
one. Every DOM node the browser creates costs memory and layout/paint time; scrolling
a page with tens of thousands of live nodes means the browser is constantly
recalculating layout across all of them, whether or not they're currently visible.
Week 3's useMemo and React.memo optimize how efficiently
React decides what to update — they don't reduce how many actual DOM nodes
exist. A perfectly memoized component tree with 20,000 real DOM nodes is still 20,000
real DOM nodes the browser has to manage.
2. The Windowing Concept
Virtualization (also called windowing) solves this by never creating DOM nodes for off-screen items in the first place — only the rows currently visible in the viewport (plus a small buffer) actually exist in the DOM at any moment.
// A list of 20,000 items, but the viewport only shows ~15 rows at once.
//
// Instead of rendering all 20,000 <div>s:
// 1. Render a container with the FULL scroll height of all 20,000 items
// (so the scrollbar behaves correctly)
// 2. As the user scrolls, calculate which ~15-20 items are currently visible
// 3. Render ONLY those items, absolutely positioned at their correct scroll offset
// 4. Everything else has zero DOM nodes -- not hidden, not display:none, simply not rendered
The scrollbar and scroll behavior feel completely normal to the user — the container genuinely has the height all 20,000 rows would occupy — but the DOM at any instant contains only a couple dozen actual elements, regardless of whether the underlying list has 200 items or 2 million.
3. TanStack Virtual
Implementing windowing correctly by hand — tracking scroll position, calculating visible ranges, handling resize — is fiddly and easy to get subtly wrong. TanStack Virtual (from the same team as Week 8's Query) handles the math; you provide the data and a way to measure each row.
npm install @tanstack/react-virtual
import { useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length, // total item count -- not how many actually render
getScrollElement: () => parentRef.current,
estimateSize: () => 56, // estimated row height in px
overscan: 5, // extra rows rendered beyond the visible viewport
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const product = products[virtualRow.index];
return (
<div
key={product.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{product.name} — ${product.price}
</div>
);
})}
</div>
</div>
);
}
getTotalSize() gives the inner div the full scroll height all 20,000
rows would occupy, so the scrollbar behaves correctly. getVirtualItems()
returns only the currently-visible rows (plus overscan extra on each
side, to reduce blank flashes during fast scrolling), each with a
start offset used to position it absolutely at its correct place in
that scroll height. Everything outside that returned list simply isn't in the
.map() output — no DOM node exists for it at all.
4. Variable-Height Rows
The example above assumes every row is the same height — fine for a uniform list, wrong for content like chat messages or comments where each row's height depends on its content. TanStack Virtual supports measuring real rendered height via a ref callback:
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // a rough guess -- corrected once each row actually renders
});
// ...
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={messages[virtualRow.index].id}
data-index={virtualRow.index}
ref={virtualizer.measureElement} // measures this row's REAL height after render
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{messages[virtualRow.index].text}
</div>
))}
estimateSize is now just a starting guess used before a row has ever
rendered; measureElement corrects it to the row's real height once it
actually mounts, and the virtualizer recalculates scroll positions for everything
below it accordingly. Note the height style is gone entirely here — the
row is left to size itself naturally based on its content, which is exactly what's
being measured.
5. When to Reach for This
Virtualization adds real complexity — absolute positioning, manual scroll container management, occasional friction with browser find-in-page or accessibility tooling that expects normal document flow. It's worth that cost specifically when a list is large enough that DOM node count is the actual bottleneck (roughly hundreds to thousands of rows and up), not as a default for every list in an app.
- Good fit: a chat history, a large data table, an infinite-scroll feed, a searchable dropdown with thousands of options.
- Poor fit: a 20-item navigation menu, a typical product grid a user will paginate through anyway. Pagination or Week 3's memoization alone is often simpler and sufficient.
Confirm with the Profiler that a long list's render/scroll time is actually the bottleneck before reaching for this — the same "measure first" discipline from Week 14, applied to a different tool.
6. Hands-on Exercise
Virtualize Week 3's 20,000-row list, then add variable-height rows
Prove virtualization works by comparing DOM node counts directly, then handle a realistic variable-content case.
Requirements:
- Take Week 3's 20,000-product array (or generate a fresh one) and render it without virtualization first. Using your browser's DevTools, count the actual
<div>elements in the DOM. - Rebuild the same list with
useVirtualizer, fixed-height rows, and confirm — again by counting DOM nodes — that only a few dozen rows exist at any moment, regardless of scroll position. - Build a second virtualized list of "chat messages" with randomly generated text of varying length (some one line, some five), using
measureElementfor real variable-height measurement. - Scroll to the bottom of the chat list, then scroll back to the top, and confirm scroll position and row alignment stay correct — a common bug in hand-rolled virtualization that TanStack Virtual handles for you.
If rows appear to "jump" or overlap while scrolling quickly, check that every rendered row includes data-index and the measureElement ref — skipping either on the variable-height version is the most common cause of misaligned rows.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why is rendering 20,000 DOM nodes slow, even with a perfectly memoized React component tree?
Why is rendering 20,000 DOM nodes slow, even with a perfectly memoized React component tree?
Memoization (Week 3) only affects how efficiently React decides what needs updating — it doesn't reduce the number of actual DOM nodes that exist. The browser itself pays memory and layout/paint cost for every live DOM node regardless of how it got there, which is a cost outside React's control entirely.
Q2
Why does the virtualized container still need a full-height inner element sized to fit all 20,000 rows, if most of them are never actually rendered?
Why does the virtualized container still need a full-height inner element sized to fit all 20,000 rows, if most of them are never actually rendered?
So the scrollbar and scroll range behave correctly — the browser needs an element with the correct total scrollable height to produce a normal-feeling scrollbar and scroll position, even though most of that space contains no actual child elements. Without it, the scrollbar would only reflect however many rows happen to be currently rendered.
Q3
What does the overscan option control, and why not set it to 0?
What does the overscan option control, and why not set it to 0?
It's the number of extra rows rendered just beyond the visible viewport on each side. With overscan: 0, scrolling quickly would briefly show blank space while new rows render just as they enter the viewport — a small overscan buffer means those rows already exist in the DOM slightly before they become visible, avoiding that flash.
Q4
Would virtualizing a 20-item navigation menu be a good idea?
Would virtualizing a 20-item navigation menu be a good idea?
No. Twenty DOM nodes are nowhere near the point where node count is an actual bottleneck, so virtualization would add real complexity (absolute positioning, scroll container management, occasional friction with browser find-in-page and accessibility tooling) for zero measurable benefit — exactly the kind of premature optimization Week 14's "measure first" principle warns against.