ReactList Virtualization

List Virtualization

Rendering 10,000 list items creates 10,000 real DOM nodes. The browser must lay them out, paint them, and keep them in memory — even the ones hundreds of pixels off-screen. At a certain scale, this makes scrolling janky and initial render painfully slow. List virtualization (also called windowing) solves this by rendering only the items currently visible in the viewport.

The Problem: DOM Node Explosion

JSX
// Naive list — renders ALL items at once
function ContactList({ contacts }) {
  return (
    <ul>
      {contacts.map(contact => (
        <li key={contact.id}>
          <Avatar src={contact.avatar} />
          <span>{contact.name}</span>
          <span>{contact.email}</span>
        </li>
      ))}
    </ul>
  )
}

// With 10,000 contacts:
// - 10,000 <li> elements in the DOM
// - 10,000 <Avatar> component instances
// - Potentially 10,000 image requests
// - Browser layout time: 200-800ms
// - Memory: easily 50-200MB for a complex row
Warning
Browsers typically start struggling at 500–1,000 DOM nodes per list. Beyond 1,000, scrolling drops below 60fps on mid-range devices, and initial render can block the main thread for several seconds.
How Windowing Works

A virtualized list renders only the items that fall within the visible viewport (the "window"), plus a small overscan buffer above and below. Items outside the window are not in the DOM at all.

To maintain correct scroll behaviour, the library wraps items in a container with a fixed total height equal to itemCount × itemHeight. Each visible item is absolutely positioned at top: index × itemHeight, so the scrollbar accurately reflects the full dataset size.

Text
Scroll container: height = 10,000 items × 48px = 480,000px
(scrollbar reflects real size)

Viewport (600px visible):
┌─────────────────────────────┐
│  item 12  (top: 576px)      │  ← overscan
│  item 13  (top: 624px)      │  ← visible
│  item 14  (top: 672px)      │  ← visible
│  item 15  (top: 720px)      │  ← visible
│  item 16  (top: 768px)      │  ← visible
│  item 17  (top: 816px)      │  ← visible
│  item 18  (top: 864px)      │  ← overscan
└─────────────────────────────┘

DOM nodes: 7   (instead of 10,000)
As you scroll, old items unmount and new ones mount
react-window: FixedSizeList

react-window is the most widely used virtualization library. Install it:

Bash
npm install react-window

FixedSizeList is the most common component — use it when every row has the same height:

JSX
import { FixedSizeList } from 'react-window'

// Row renderer — receives index, style (from react-window), and data
const Row = ({ index, style, data }) => {
  const contact = data[index]
  return (
    // CRITICAL: spread the style prop — it contains the absolute positioning
    <div style={style} className="contact-row">
      <img src={contact.avatar} alt={contact.name} />
      <span>{contact.name}</span>
      <span>{contact.email}</span>
    </div>
  )
}

function ContactList({ contacts }) {
  return (
    <FixedSizeList
      height={600}          // height of the visible viewport (px)
      width="100%"          // width of the list
      itemCount={contacts.length}  // total number of items
      itemSize={64}         // height of each row (px) — must be fixed
      itemData={contacts}   // passed as data to each Row
      overscanCount={3}     // render 3 extra rows above/below for smoother scroll
    >
      {Row}
    </FixedSizeList>
  )
}
Note
Always spread the `style` prop from react-window onto the row's root element. Without it, the absolute positioning breaks and all rows stack at position 0.
VariableSizeList for Dynamic Row Heights

When rows have different heights (e.g., chat messages, expandable items), use VariableSizeList. You provide an itemSize function instead of a fixed value:

JSX
import { VariableSizeList } from 'react-window'

// Map of precomputed heights (measure once, cache in a ref/state)
const rowHeights = { 0: 80, 1: 48, 2: 120, 3: 48 /* ... */ }

function getItemSize(index) {
  return rowHeights[index] ?? 48  // default height for unmeasured items
}

function MessageList({ messages }) {
  return (
    <VariableSizeList
      height={600}
      width="100%"
      itemCount={messages.length}
      itemSize={getItemSize}
      estimatedItemSize={60}  // hint for total height calculation
    >
      {({ index, style }) => (
        <div style={style}>
          <Message message={messages[index]} />
        </div>
      )}
    </VariableSizeList>
  )
}
TanStack Virtual (react-virtual)

TanStack Virtual is a newer, framework-agnostic alternative. It is a headless library — it gives you the math (which items to render, what offset to apply) and you handle the rendering yourself, giving full control over markup:

Bash
npm install @tanstack/react-virtual

JSX
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

function VirtualList({ items }) {
  const parentRef = useRef(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,        // estimated row height
    overscan: 5,
  })

  return (
    <div
      ref={parentRef}
      style={{ height: '600px', overflow: 'auto' }}
    >
      {/* Total height spacer to give the scrollbar the right size */}
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualRow => (
          <div
            key={virtualRow.index}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <Row item={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}
Overscan: Smoother Scrolling

Overscan renders a few extra rows beyond the visible area. Without it, fast scrolling reveals blank space while the new rows mount. An overscan of 3–5 rows is usually sufficient without significantly increasing DOM node count.

Infinite Scroll + Virtualization

Virtualization pairs naturally with infinite scroll — load the next page when the user scrolls near the bottom, append items, and the virtualizer handles the growing list:

JSX
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useEffect } from 'react'

function InfiniteList({ items, fetchNextPage, hasNextPage, isFetching }) {
  const parentRef = useRef(null)

  const virtualizer = useVirtualizer({
    count: hasNextPage ? items.length + 1 : items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 64,
    overscan: 5,
  })

  const virtualItems = virtualizer.getVirtualItems()
  const lastItem = virtualItems[virtualItems.length - 1]

  useEffect(() => {
    if (!lastItem) return
    // If the last virtual item is the "loader" row, fetch the next page
    if (lastItem.index >= items.length - 1 && hasNextPage && !isFetching) {
      fetchNextPage()
    }
  }, [lastItem, hasNextPage, isFetching, fetchNextPage, items.length])

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualItems.map(row => (
          <div
            key={row.index}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${row.start}px)`,
              width: '100%',
            }}
          >
            {row.index < items.length
              ? <Item item={items[row.index]} />
              : <LoadingRow />}
          </div>
        ))}
      </div>
    </div>
  )
}
When to Use Virtualization

Item count

Recommendation

< 100

No virtualization needed

100–500

Consider it if rows are complex or frequently re-rendered

500–1,000

Strongly consider it, especially on mobile

1,000

Always virtualize

  • Use FixedSizeList when all rows are the same height — simplest and most performant

  • Use VariableSizeList or TanStack Virtual when rows have different heights

  • Always remember to spread the style prop from react-window row renderers

  • Set overscanCount to 3–5 for smooth fast-scrolling without too many extra nodes

  • Combine with pagination or infinite scroll for server-driven large datasets

Tip
If your rows contain images, combine virtualization with lazy image loading (IntersectionObserver or the native loading="lazy" attribute). Virtualization removes DOM nodes but lazy loading prevents network requests for off-screen images.