List Virtualization
Rendering a list of ten thousand rows means React creates ten thousand DOM nodes, lays them all out, and keeps them alive even though the user can only see a dozen at a time. That cost shows up as a slow mount, janky scrolling, and a heavy memory footprint. Virtualization—also called windowing—fixes this by rendering only the rows currently in (or near) the viewport and recycling them as the user scrolls. It is the single highest-impact technique for large lists, tables, and grids.
Why long lists are slow
The browser does not care that a row is off-screen; if it exists in the DOM, it occupies memory and participates in layout and paint. A list with 5,000 items and three elements per row produces 15,000 nodes. Mounting that blocks the main thread, and every subsequent re-render has to reconcile all of them.
The user, however, can only ever see what fits on screen—typically 10–30 rows. Virtualization exploits that gap: keep a tall scroll container so the scrollbar behaves correctly, but only mount the handful of rows that are actually visible, plus a small overscan buffer above and below to avoid blank flashes during fast scrolls.
How windowing works
A windowing library measures the scroll container, reads scrollTop, and computes which item indices fall inside the visible window. It renders only those items, absolutely positioned at the correct offset, inside a spacer element whose total height equals itemCount * itemSize. As you scroll, the visible slice shifts and rows are reused.
// Conceptually, the library computes:
const startIndex = Math.floor(scrollTop / itemSize);
const visibleCount = Math.ceil(viewportHeight / itemSize);
const endIndex = startIndex + visibleCount + overscan;
// then renders items[startIndex .. endIndex]
You never write this yourself—react-window and TanStack Virtual handle it—but knowing the model explains the trade-offs.
react-window: fixed-size rows
react-window is the lightweight, batteries-included choice. For uniform row heights, use FixedSizeList. Install it with npm install react-window.
import { FixedSizeList } from "react-window";
const rows = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `User ${i}`,
}));
function Row({ index, style }) {
const row = rows[index];
return (
<div style={style} className="row">
{row.id}. {row.name}
</div>
);
}
export default function UserList() {
return (
<FixedSizeList
height={400}
width="100%"
itemCount={rows.length}
itemSize={35}
>
{Row}
</FixedSizeList>
);
}
The style prop is mandatory—it carries the absolute position, top, and height that place the row correctly. Forgetting to spread it onto your root element is the most common bug, and it causes every row to stack at the top.
Output:
DOM nodes mounted: ~15 (visible rows + overscan)
Instead of: 10000
Variable-size rows
When rows differ in height—comments, chat messages, cards with images—use VariableSizeList and provide a function that returns each item’s size.
import { VariableSizeList } from "react-window";
const getItemSize = (index) => (index % 2 === 0 ? 80 : 50);
export default function FeedList({ items }) {
return (
<VariableSizeList
height={500}
width="100%"
itemCount={items.length}
itemSize={getItemSize}
>
{({ index, style }) => (
<article style={style}>{items[index].text}</article>
)}
</VariableSizeList>
);
}
The catch: you must know each height up front. If heights depend on rendered content you cannot measure in advance, reach for TanStack Virtual.
TanStack Virtual: measure-as-you-go
TanStack Virtual is a headless hook—it gives you positions and indices, and you own the markup. Its standout feature is measureElement, which measures real DOM nodes after they render, so dynamic content “just works” without precomputed sizes.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
export default function Comments({ comments }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: comments.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div
style={{ height: virtualizer.getTotalSize(), position: "relative" }}
>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${item.start}px)`,
}}
>
{comments[item.index].body}
</div>
))}
</div>
</div>
);
}
Choosing a library
| Need | react-window | TanStack Virtual |
|---|---|---|
| Fixed-height rows | Excellent | Excellent |
| Known variable heights | VariableSizeList | estimateSize |
| Dynamic / unknown heights | Hard | measureElement (built-in) |
| Horizontal / grid | FixedSizeGrid | Yes |
| Markup control | Render-prop only | Full (headless) |
| Bundle size | Tiny (~3 KB) | Small (~5 KB) |
Tip: Always give virtualized rows a stable
keytied to the data (e.g.item.id), not the array index. Index keys break when the underlying list is sorted or filtered, causing rows to swap content incorrectly during reuse.
Gotcha: A virtualized list needs a bounded height to know its viewport. If the scroll container has no fixed or flex-derived height, nothing virtualizes and you get a blank area. Set
heightexplicitly or let a flex parent constrain it.
Best Practices
- Reach for virtualization once a list comfortably exceeds a few hundred rows; below that, the added complexity rarely pays off.
- Always spread the provided
style(react-window) or apply thetransformoffset (TanStack) onto each row’s root element. - Keep an
overscanof 3–5 rows to eliminate blank flashes during fast scrolling without over-rendering. - Prefer fixed-size lists when heights are uniform—they skip per-item measurement and are the fastest path.
- Use stable, data-derived keys so row recycling never mixes up content.
- Wrap expensive row components in
React.memoso scrolling does not re-render rows whose data is unchanged. - Remember that virtualized rows are not in the DOM until scrolled into view—
Ctrl+Ffind, anchor links, and naive screenshot tooling may miss them.