Skip to content
Back to Design Hub

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

FAQ