Virtualization with TanStack Virtual
Why 10,000 Rows Kill Your App
You might think "modern browsers are fast, how bad can 10,000 rows be?" Pretty bad, actually. Every DOM node costs memory (1-2KB each). Every React component takes time to render. Mounting 10,000 rows means:
- 10,000 DOM nodes: ~15MB of memory just for the nodes
- 10,000 component renders: 500ms+ initial render time
- 10,000 event listeners: If each row has click handlers
- Scroll performance: Browser must composite all 10,000 rows on every scroll frame
The solution is beautifully simple in concept: render only what's visible. If the viewport shows 20 rows, render 20 rows. As the user scrolls, unmount rows leaving the viewport and mount rows entering it. The user sees a complete list, but only ~25 DOM nodes exist at any time.
// WITHOUT virtualization: renders all 10,000 rows
function NaiveList({ items }) {
return (
<div style={{ height: '600px', overflow: 'auto' }}>
{items.map(item => (
<Row key={item.id} item={item} /> // 10,000 DOM nodes
))}
</div>
);
}
// WITH virtualization: renders ~25 rows regardless of total
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<Row
key={virtualRow.key}
item={items[virtualRow.index]}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
/>
))}
</div>
</div>
);
}
The Mental Model
Think of virtualization as a theater set. A real city has thousands of buildings. But in a movie, you only need the buildings the camera can see. Set designers build facades for the visible buildings and move them as the camera pans. The audience sees a complete city, but only 10 buildings exist at any time.
The scroll container is the camera. The visible rows are the facades. As the user scrolls (camera pans), rows entering the frame are built and rows leaving are dismantled. The total height of the container matches what a full list would be (so the scrollbar looks right), but only visible rows have actual DOM nodes.
TanStack Virtual Setup
Basic List Virtualization
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedList({ items }) {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: items.length, // Total number of items
getScrollElement: () => parentRef.current, // Scroll container
estimateSize: () => 50, // Estimated row height in px
overscan: 5, // Extra rows to render outside viewport
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
{/* Inner container: height matches total list height for scrollbar */}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* Only renders visible rows + overscan */}
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<Row item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
Variable Height Rows
Here's where things get interesting. Real-world lists rarely have uniform row heights:
function VirtualizedChat({ messages }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80, // Best guess for initial layout
// Measure actual size after render
measureElement: (element) => element.getBoundingClientRect().height,
});
return (
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
width: '100%',
}}
>
<ChatMessage message={messages[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
The measureElement callback measures each row's actual height after rendering. This handles messages of varying length, images that load asynchronously, and expandable content.
Virtual Grid
For two-dimensional virtualization (image galleries, spreadsheets):
function VirtualGrid({ items, columns }) {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / columns),
getScrollElement: () => parentRef.current,
estimateSize: () => 200,
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: columns,
getScrollElement: () => parentRef.current,
estimateSize: () => 200,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
columnVirtualizer.getVirtualItems().map(virtualColumn => {
const index = virtualRow.index * columns + virtualColumn.index;
if (index >= items.length) return null;
return (
<div
key={`${virtualRow.index}-${virtualColumn.index}`}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
}}
>
<GridItem item={items[index]} />
</div>
);
})
))}
</div>
</div>
);
}
Virtualization breaks native browser search (Ctrl+F). Since most rows don't exist in the DOM, the browser can't find text in them. For searchable lists, combine virtualization with your own search UI, or consider whether you really need 10,000 rows (pagination might be better UX).
Also, accessibility tools (screen readers) may have difficulty with virtualized lists. Use proper ARIA attributes (role="list", role="listitem", aria-setsize, aria-posinset) to communicate the full list structure to assistive technology.
Production Scenario: The Admin Table
This is basically a rite of passage for frontend engineers. An admin panel shows a table of 50,000 log entries. Without virtualization, the page takes 8 seconds to load and eats 2GB of memory. With virtualization:
function LogTable({ logs }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 10,
});
return (
<div ref={parentRef} className="h-[80vh] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 bg-white z-10">
<tr>
<th>Timestamp</th>
<th>Level</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{/* Spacer row for total height */}
<tr style={{ height: `${virtualizer.getTotalSize()}px` }}>
<td colSpan={3} style={{ padding: 0 }}>
<div style={{ position: 'relative', height: '100%' }}>
{virtualizer.getVirtualItems().map(virtualRow => {
const log = logs[virtualRow.index];
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
display: 'flex',
width: '100%',
}}
>
<span className="w-48">{log.timestamp}</span>
<span className="w-24">{log.level}</span>
<span className="flex-1">{log.message}</span>
</div>
);
})}
</div>
</td>
</tr>
</tbody>
</table>
</div>
);
}
Results: load time drops from 8s to 100ms. Memory from 2GB to 15MB. Scroll stays at 60fps.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Virtualizing lists with fewer than 100 items 100 DOM nodes are trivial for the browser. The complexity of virtualization (absolute positioning, scroll calculations, dynamic measuring) isn't worth it for small lists | Virtualization adds complexity. For short lists, just render all items |
| Forgetting the overscan prop Without overscan, rows are only rendered when they enter the viewport. Fast scrolling can outpace React's render cycle, showing blank space. Overscan pre-renders rows just outside the viewport | Set overscan to 3-10 items to prevent blank flashes during fast scrolling |
| Using virtualization when pagination would be better UX Virtualization breaks Ctrl+F, makes accessibility harder, and encourages endless scrolling. Pagination with URL state is often better for data tables | Consider pagination for searchable, filterable data. Virtualization for browseable, sequential data |
| Measuring rows synchronously in the render phase Synchronous DOM measurement in render causes layout thrashing. TanStack Virtual's measurement system avoids this by measuring after paint | Use measureElement callback or estimateSize for initial layout, with post-render measurement |
Challenge
Challenge: When to virtualize?
// For each scenario, decide: virtualize, paginate, or render all?
// Scenario 1: A dropdown with 50 options
// Scenario 2: An email inbox with 5,000 messages
// Scenario 3: A product catalog with 200 items and filtering
// Scenario 4: A chat history with 10,000 messages
// Scenario 5: A data table with 100,000 rows and sorting/filtering
Show Answer
Scenario 1: Render all. 50 items is trivial for the browser. Virtualization would add unnecessary complexity to a simple dropdown.
Scenario 2: Virtualize. Email inboxes are sequential, browseable lists. Users scroll through them linearly. 5,000 messages would be slow to render. Virtualization with variable height (messages have different lengths) is ideal.
Scenario 3: Render all (possibly paginate). 200 items is borderline but usually fine to render. If filtering reduces the list to 20-30 items, the initial 200 render is acceptable. If each item has heavy components (images, charts), consider pagination.
Scenario 4: Virtualize. Chat history is inherently sequential and scrollable. 10,000 messages with variable heights (text, images, embeds) is a classic virtualization use case. Use scrollToEnd for initial positioning and measureElement for variable heights.
Scenario 5: Paginate. 100,000 rows with sorting and filtering is best served by server-side pagination. The user can't meaningfully browse 100K rows by scrolling. Pagination with URL state lets them bookmark, share, and search specific pages. If the team insists on infinite scroll, virtualize with server-side windowing (fetch only the visible page from the server).
Quiz
Key Rules
- 1Virtualization renders only visible rows (typically 20-50) regardless of total list size. 10,000 items use the same memory as 25.
- 2Use virtualization for lists over 500 items, or over 100 items if each row is expensive to render.
- 3Always set overscan (3-10 items) to prevent blank flashes during fast scrolling.
- 4Use transform: translateY for positioning rows — it's compositor-only and doesn't trigger layout recalculation.
- 5For variable-height rows, use measureElement to measure after render. Don't measure synchronously during render.
- 6Virtualization breaks browser search (Ctrl+F) and complicates accessibility. Add custom search UI and proper ARIA attributes.