TL;DR: TanStack Virtual renders only visible items in a large list. Pass item count and size to
useVirtualizer(), map overvirtualizer.getVirtualItems(), and apply the offset as a CSS transform. Keeps the DOM small whether you have 1,000 or 100,000 items. Official docs: tanstack.com/virtual.
What is TanStack Virtual?
TanStack Virtual is a headless virtualisation library - it calculates which items in a large list are visible in the scroll window and gives you their positions. You render only those items instead of all 10,000+. This keeps the DOM small and scrolling smooth regardless of dataset size.
Unlike react-virtualized or react-window, it renders no HTML itself. You control the markup.
When to use it
Virtualise lists when they have more than ~200 - 500 items. Below that, the DOM overhead is manageable. Above it, you'll see:
- Slow initial render (browser layout calculation for thousands of elements)
- Janky scrolling (style recalculation on every frame)
- High memory usage
Use TanStack Virtual when building:
- Long data tables (pair with TanStack Table's virtualisation guide)
- Chat message history
- Log viewers
- Infinite scroll feeds
Key Features
useVirtualizer- core hook for vertical, horizontal, and grid virtualisation- Variable sizes - items can have different heights; measured dynamically
- Smooth scrolling -
scrollToIndexandscrollToOffsetwith behaviour options - Reverse mode - bottom-anchored lists for chat UIs
- Overscan - render extra items above/below viewport to reduce blank flashes
Installation
npm install @tanstack/react-virtual
Basic Usage - Long List
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // estimated item height in px
overscan: 5,
})
return (
// scrollable container must have fixed height and overflow-auto
<div ref={parentRef} style={{ height: '600px', overflowY: 'auto' }}>
{/* total height - creates scroll space for all items */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
)
}
Variable Heights
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
// measureElement lets the virtualizer measure actual rendered heights
measureElement: (el) => el.getBoundingClientRect().height,
})
// In the rendered item, attach the ref:
{virtualizer.getVirtualItems().map(item => (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
style={{ position: 'absolute', top: 0, transform: `translateY(${item.start}px)` }}
>
<Message message={messages[item.index]} />
</div>
))}
TypeScript Tips
The useVirtualizer return is fully typed. virtualItem.index is a number - use it to index your data array:
interface LogEntry { id: string; level: 'info' | 'warn' | 'error'; message: string }
const vItems = virtualizer.getVirtualItems() // VirtualItem[]
vItems.map(vi => {
const log: LogEntry = logs[vi.index] // typed access
return <LogRow key={vi.key} log={log} />
})
Common Gotchas
- Container must have explicit height - the scroll container needs
heightandoverflow: auto/scroll; without it the virtualizer calculates zero visible items keymust bevirtualItem.key- notvirtualItem.index. The key is stable even when items are reordered- Smooth scroll to index - call
virtualizer.scrollToIndex(index, { behavior: 'smooth' })after data loads for chat-scroll-to-bottom behaviour
How windowing actually works under the hood
The idea behind virtualization is older than React. You have a scroll container with a fixed height, say 600 pixels. Even if the dataset has 100,000 rows, only about a dozen fit on screen at once. So why render 100,000 DOM nodes? You render the visible dozen plus a small buffer, and you fake the scrollbar by setting the inner container to the full height all the rows would occupy.
TanStack Virtual does the math. It reads the scroll position, figures out which indexes fall inside the viewport, and hands you those VirtualItem objects with their start offsets. You absolutely position each rendered item using that offset. As the user scrolls, the set of visible indexes shifts, old items unmount, new ones mount, and the DOM node count stays flat. That's the whole game, and it's why memory usage doesn't climb with dataset size.
Tuning overscan without overdoing it
Overscan is the number of extra items rendered above and below the visible window. Set it too low and fast scrolling shows blank gaps before new rows paint. Set it too high and you throw away the performance you came for. A value between 3 and 10 covers most cases. I usually start at 5 and only bump it if I see flashing during a flick scroll on a slower device.
There's a real tradeoff here that people miss. Heavier row components (images, charts, nested lists) make each extra overscanned item more expensive. For those, keep overscan small. For simple text rows, a larger overscan is cheap insurance against blank flashes.
Grids, horizontal lists, and reverse scrolling
useVirtualizer isn't limited to vertical lists. Pass horizontal: true for a horizontal track, which is great for carousels or timelines with thousands of frames. For grids, you run two virtualizers - one for rows, one for columns - and combine their virtual items to render only the visible cells. A spreadsheet with a million cells stays smooth because you might only ever render 200 of them.
Chat interfaces want the opposite of normal scrolling: new messages appear at the bottom and the view stays pinned there. You handle this by calling scrollToIndex on the last item after each new message and accounting for the user scrolling up to read history. Don't yank them back to the bottom while they're reading - check the scroll position first.
Measuring dynamic content correctly
Fixed-height rows are simple. Variable heights are where bugs live. The measureElement approach measures each row after it renders and corrects the layout. The non-obvious requirement is the data-index attribute on every measured element. The virtualizer uses it to map a DOM node back to its data index. Forget it and your offsets drift, items overlap, and the scrollbar jumps. Attach ref={virtualizer.measureElement} and data-index={item.index} together, every time.
Related
- TanStack Table - combine with TanStack Virtual for virtualised data grids handling thousands of rows
- TanStack Query - use TanStack Query for infinite scroll data fetching; TanStack Virtual for rendering the rows