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
// 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 rowHow 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.
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:
npm install react-window
FixedSizeList is the most common component — use it when every row has the same height:
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>
)
}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:
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:
npm install @tanstack/react-virtual
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:
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
FixedSizeListwhen all rows are the same height — simplest and most performantUse
VariableSizeListor TanStack Virtual when rows have different heightsAlways remember to spread the
styleprop from react-window row renderersSet
overscanCountto 3–5 for smooth fast-scrolling without too many extra nodesCombine with pagination or infinite scroll for server-driven large datasets