Virtualization Pattern: Render Thousands of Rows Without Lag
Use a virtualized list to render only what's visible and keep the DOM lean.
A plain .map() over 1,000 rows mounts 1,000 DOM nodes before anyone scrolls a pixel. Virtualization mounts only the rows visible in the viewport plus a small buffer, and recycles them as you scroll.
The cost of 1,000 eager rows
The naive version renders every row up front. The browser lays out and paints all 1,000 nodes before showing anything, and scrolling drags them through reflow. Memory spikes too, especially on mobile.
const items = Array.from({ length: 1000 }, (_, index) => `Item ${index + 1}`);
function App() {
return (
<div>
<h1>Virtualization Pattern</h1>
<p>Rendering {items.length} items efficiently:</p>
<div style={{ height: 400, overflow: "auto" }}>
{items.map((item, index) => (
<div key={index} style={{ padding: "8px" }}>{item}</div>
))}
</div>
</div>
);
}
export default App;
Render only the visible slice
A virtualized list tracks scrollTop and renders just the rows that fit the viewport. This is a simplified hand-rolled version that mirrors how react-window behaves:
import { useState, useMemo } from "react";
const items = Array.from({ length: 1000 }, (_, index) => `Item ${index + 1}`);
// Simplified virtualization - only render visible items
function VirtualizedList({ items, containerHeight = 400, itemHeight = 35 }) {
const [scrollTop, setScrollTop] = useState(0);
const visibleRange = useMemo(() => {
const start = Math.floor(scrollTop / itemHeight);
const end = Math.min(
start + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
return { start, end };
}, [scrollTop, containerHeight, itemHeight, items.length]);
const visibleItems = items.slice(visibleRange.start, visibleRange.end);
const offsetY = visibleRange.start * itemHeight;
return (
<div
style={{
height: containerHeight,
overflow: "auto",
position: "relative"
}}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
<div style={{ height: items.length * itemHeight, position: "relative" }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, idx) => (
<div
key={visibleRange.start + idx}
style={{ height: itemHeight, padding: "8px" }}
>
{item}
</div>
))}
</div>
</div>
</div>
);
}
function App() {
return (
<div>
<h1>Virtualization Pattern</h1>
<p>Rendering {items.length} items efficiently:</p>
<VirtualizedList items={items} />
</div>
);
}
export default App;
The math is short. scrollTop / itemHeight gives the first visible index, and slice cuts a window wide enough to fill the container height. A full-height spacer keeps the scrollbar honest while translateY shifts the rendered rows into place. After wiring it up, I verify in DevTools: few mounted nodes, smooth scrolling.
In practice, react-window
For real lists I reach for react-window rather than hand-rolling. The lesson code wraps this in a List component with the same shape:
<List
height={400}
rowCount={items.length}
rowHeight={35}
rowComponent={Row}
rowProps={{ items }}
/>
The Row component renders items[index] and applies the style the list hands it. With react-window itself, prefer FixedSizeList with itemCount, itemSize, and itemData. Row components stay pure and cheap, without expensive calculations inside the renderer, and the list gets a fixed height and a consistent row height, because virtualization needs a viewport to work.