Virtualization for Long Lists
Windowing 10k+ rows by rendering only the visible slice, with stable keys and dynamic heights.
Component PatternsAdvanced
Why
Rendering 10,000 DOM nodes freezes the browser for seconds and burns 100MB+ memory. Virtualization renders ~20 visible rows, keeping scroll buttery and memory flat regardless of data size.
Compound component patterns are used by Radix UI, Headless UI, and shadcn/ui — the three most adopted headless libraries in 2024. They reduce prop drilling by 70% compared to flat component APIs.
When
Virtualize when the list exceeds ~200 rows or when scroll jank appears. Below that, the overhead of measurement isn't worth it; native scroll is fine.
How
1. Pick a lib — @tanstack/react-virtual (headless, recommended) or react-window (batteries-included)
2. Measure — fixed height is trivial; dynamic needs a measureRef or estimated size + resize observer
3. Render — only items in [scrollTop, scrollTop + viewportHeight] plus overscan (3-5 rows)
4. Spacer — a tall div with the total height keeps the scrollbar accurate
5. Keys — stable, unique keys per item (never array index) so React reuses DOM across scroll
6. Scroll container — the virtualizer attaches to a scroll parent; ensure it has a bounded height
7. Infinite load — append on near-bottom via onItemsRendered, show sentinel + loader
Example
import { useVirtualizer } from "@tanstack/react-virtual";
function BigList({ items }: { items: Row[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // px per row
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((vi) => (
<div key={items[vi.index].id}
style={{ position: "absolute", top: vi.start, height: 48 }}>
{items[vi.index].name}
</div>
))}
</div>
</div>
);
}
Common Problems
- ⚠Array index as key — rows reorder on filter/sort and React reuses the wrong DOM
- ⚠No bounded height on scroll parent — virtualizer measures 0px, renders nothing
- ⚠Dynamic heights without measurement — estimateSize is wrong, rows overlap or gap
Best Practices
- ✓Use a stable unique key (item.id) — never the array index, prevents DOM reuse bugs on reorder
- ✓Give the scroll parent a bounded height — height: 100vh or a fixed px, never auto
- ✓Measure dynamic heights with a resize observer — estimateSize is just a hint for the scrollbar