Week 15: List Virtualization

Week 3's 20,000-row product list dodged its performance problem by filtering the array down before rendering. Sometimes you genuinely need to show all of it. This week covers the technique that makes that possible: rendering only the handful of rows actually visible on screen, no matter how large the underlying list is.

Module 10 of 13 Week 15 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain why rendering thousands of DOM nodes is slow, independent of React
  • Implement a virtualized list with TanStack Virtual
  • Virtualize a list with variable-height rows

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.

the core idea
// 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.

terminal
npm install @tanstack/react-virtual
VirtualProductList.tsx
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:

measuring real, variable heights
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.
Virtualization and Week 14's DevTools Profiler go together

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

Hands-on

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:

  1. 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.
  2. 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.
  3. Build a second virtualized list of "chat messages" with randomly generated text of varying length (some one line, some five), using measureElement for real variable-height measurement.
  4. 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.
Hint

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?

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?

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?

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?

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.