@tanstack/react-virtual vs react-window vs react-virtuoso: List Virtualization in 2026
TL;DR
For most new projects: react-virtuoso is the pragmatic choice — the richest API with built-in support for dynamic heights, groups, sticky headers, and infinite scroll out of the box. @tanstack/react-virtual is the right choice if you want maximum flexibility and zero opinions (headless). react-window is mature and battle-tested but shows its age with fixed-size-only limitations and no active development.
Key Takeaways
- react-window: ~1.9M weekly downloads — stable but no longer actively developed by maintainer
- react-virtuoso: ~2.1M weekly downloads — richest API, best for complex use cases
- @tanstack/react-virtual: ~1.3M weekly downloads — most flexible, headless, pairs well with TanStack ecosystem
- Dynamic row heights (variable size items): react-window requires workarounds; @tanstack/react-virtual and react-virtuoso handle natively
- react-virtuoso wins for grids, groups, sticky headers, and table virtualization
- @tanstack/react-virtual wins for custom implementations and non-React environments
Why Virtualization Matters
Rendering 10,000 DOM nodes destroys performance. Virtualization renders only the visible items:
Without virtualization: 10,000 <li> in DOM → 200ms initial render, jank on scroll
With virtualization: ~20 <li> in DOM at any time → <10ms render, smooth scroll
The tradeoff: complexity, SSR considerations, accessibility challenges.
Download Trends
| Package | Weekly Downloads | Last Major Version | Bundle Size |
|---|---|---|---|
react-window | ~1.9M | 1.8 (2019) | ~6KB |
react-virtuoso | ~2.1M | 4.x (2024) | ~17KB |
@tanstack/react-virtual | ~1.3M | 3.x (2024) | ~5KB |
@tanstack/virtual-core | ~1.3M | 3.x (2024) | ~4KB |
react-window
Created by Brian Vaughn (React core team), react-window was the gold standard for list virtualization from 2019–2022. It's stable, well-documented, and used in production by thousands of apps.
import { FixedSizeList, VariableSizeList } from "react-window"
// Fixed height rows (simplest case):
function FixedList({ items }: { items: string[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>{items[index]}</div>
)
return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={50}
width={600}
>
{Row}
</FixedSizeList>
)
}
// Variable height rows (requires pre-measuring heights):
const getItemSize = (index: number) => heights[index] // You must know this upfront
function VariableList({ items }: { items: string[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>{items[index]}</div>
)
return (
<VariableSizeList
height={400}
itemCount={items.length}
itemSize={getItemSize} // Must return size synchronously
width={600}
>
{Row}
</VariableSizeList>
)
}
The react-window problem:
For variable-height items (common in chat apps, feeds, or any dynamic content), you must know all item heights upfront. This is rarely possible with real data. The community workaround (react-virtualized-auto-sizer + ResizeObserver) works but is awkward.
react-window's maintainer has signaled they won't be adding these features. The library is "done."
@tanstack/react-virtual
Part of the TanStack suite (TanStack Query, TanStack Table, TanStack Router). v3 was a complete rewrite with a headless, framework-agnostic core:
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: () => 50, // Estimate; actual size measured after render
overscan: 5, // Render 5 extra items outside viewport
})
return (
<div
ref={parentRef}
style={{ height: 400, overflow: "auto" }}
>
{/* Total height maintains scrollbar position */}
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
ref={virtualizer.measureElement} // Auto-measures actual height
style={{
position: "absolute",
top: 0,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
)
}
Dynamic heights with measureElement:
The measureElement ref automatically measures each item after render and recalculates positions. This means you can have arbitrary-height content without pre-measuring anything:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Just an estimate — real sizes measured on render
})
// Each item auto-reports its actual height
return (
<div ref={virtualizer.measureElement} key={virtualItem.key}>
<ExpandableCard item={items[virtualItem.index]} />
</div>
)
Framework-agnostic core:
import { Virtualizer } from "@tanstack/virtual-core"
// Works with any framework (Vue, Solid, Svelte) via the core package
const virtualizer = new Virtualizer({
count: 10000,
getScrollElement: () => scrollElement,
estimateSize: () => 50,
observeElementRect: observeElementRect,
observeElementOffset: observeElementOffset,
scrollToFn: elementScroll,
onChange: (instance) => render(instance.getVirtualItems()),
})
react-virtuoso
react-virtuoso takes the opposite approach from @tanstack/react-virtual — it's batteries-included:
import { Virtuoso, GroupedVirtuoso, TableVirtuoso } from "react-virtuoso"
// Basic list — dead simple API:
function SimpleList({ items }: { items: string[] }) {
return (
<Virtuoso
style={{ height: 400 }}
data={items}
itemContent={(index, item) => <div>{item}</div>}
/>
)
}
// Grouped list with sticky headers:
function GroupedList() {
const groupCounts = [10, 20, 15] // Items per group
const groupContent = (index: number) => (
<div style={{ background: "#eee", padding: "8px" }}>Group {index}</div>
)
return (
<GroupedVirtuoso
groupCounts={groupCounts}
groupContent={groupContent}
itemContent={(index) => <div>Item {index}</div>}
/>
)
}
// Infinite scroll built-in:
function InfiniteList({ loadMore }: { loadMore: () => void }) {
const [items, setItems] = useState<string[]>([...initial])
return (
<Virtuoso
style={{ height: 400 }}
data={items}
endReached={loadMore}
itemContent={(index, item) => <div>{item}</div>}
components={{
Footer: () => <div>Loading...</div>
}}
/>
)
}
// Virtual table (headless table cells + virtualization):
function VirtualTable({ rows, columns }) {
return (
<TableVirtuoso
data={rows}
fixedHeaderContent={() => (
<tr>{columns.map(col => <th key={col.key}>{col.label}</th>)}</tr>
)}
itemContent={(index, row) => (
columns.map(col => <td key={col.key}>{row[col.key]}</td>)
)}
/>
)
}
react-virtuoso built-in features:
- Dynamic item heights — auto-measured, no configuration
- Sticky group headers —
GroupedVirtuosocomponent - Top/bottom loading (bi-directional infinite scroll)
- Prepend items without scroll jump (e.g., chat history loading)
- ResizeObserver-based — handles item height changes after render
- First-render optimization — renders above-the-fold immediately
TableVirtuoso— virtual table with fixed headersScrollSeekPlaceholder— show placeholder during fast scroll
Feature Comparison
| Feature | @tanstack/react-virtual | react-virtuoso | react-window |
|---|---|---|---|
| Dynamic heights | ✅ measureElement | ✅ Auto | ⚠️ Manual |
| Sticky headers/groups | ❌ DIY | ✅ Built-in | ❌ |
| Infinite scroll | ❌ DIY | ✅ endReached | ❌ |
| Horizontal virtualization | ✅ | ✅ | ✅ |
| Grid virtualization | ✅ | ❌ | ✅ (FixedSizeGrid) |
| Virtual table | ❌ DIY | ✅ TableVirtuoso | ❌ |
| Bi-directional loading | ❌ | ✅ | ❌ |
| Bundle size | ~5KB | ~17KB | ~6KB |
| Framework agnostic | ✅ Core package | ❌ React only | ❌ React only |
| TypeScript | ✅ First-class | ✅ First-class | ⚠️ Types via @types |
| Active maintenance | ✅ | ✅ | ⚠️ Maintenance mode |
Performance Characteristics
For a list of 100,000 items:
| Library | Initial Render | Scroll FPS | Memory (DOM nodes) |
|---|---|---|---|
| No virtualization | ~2000ms | ~15 FPS | 100,000 |
| react-window | ~8ms | 60 FPS | ~20 |
| @tanstack/react-virtual | ~6ms | 60 FPS | ~20 + overscan |
| react-virtuoso | ~12ms | 60 FPS | ~20 + buffer |
react-virtuoso has slightly more overhead due to its ResizeObserver usage and richer feature set, but the difference is imperceptible in normal usage.
When to Use Each
Choose @tanstack/react-virtual if:
- You want maximum control with a headless, composable API
- You're already using TanStack Query or TanStack Table
- You need non-React support (Vue, Solid, Svelte) via the core
- You're building drag-and-drop sortable virtual lists (pairs with @dnd-kit)
- You want to avoid the 17KB cost of react-virtuoso
Choose react-virtuoso if:
- You need dynamic item heights without measurement boilerplate
- You're building grouped lists with sticky headers
- You need infinite scroll (top, bottom, or both)
- You're virtualizing a table with fixed headers
- You want the richest API without DIY
Choose react-window if:
- You have an existing react-window implementation that works
- All your items are fixed height (the one thing react-window excels at)
- Absolute minimum bundle size is critical (~6KB vs 17KB)
Real-World Usage Patterns
Chat Interface (react-virtuoso)
// Bi-directional infinite scroll with prepend-on-load (no scroll jump):
function ChatMessages({ channelId }: { channelId: string }) {
const [messages, setMessages] = useState(initialMessages)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const loadOlder = async () => {
const older = await fetchOlderMessages(messages[0].id)
// Virtuoso handles scroll position preservation on prepend
setMessages([...older, ...messages])
}
return (
<Virtuoso
ref={virtuosoRef}
style={{ height: "100%" }}
data={messages}
startReached={loadOlder} // Load older messages at top
followOutput="smooth" // Auto-scroll for new messages
itemContent={(_, msg) => <ChatMessage message={msg} />}
initialTopMostItemIndex={messages.length - 1} // Start at bottom
/>
)
}
Data Table (TanStack Virtual + TanStack Table)
// @tanstack/react-virtual pairs naturally with @tanstack/react-table:
function VirtualDataTable({ data }: { data: Row[] }) {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
const { rows } = table.getRowModel()
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 10,
})
return (
<div ref={parentRef} style={{ height: 500, overflow: "auto" }}>
<table>
<thead>{/* TanStack Table headers */}</thead>
<tbody style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
key={row.id}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
Methodology
Download data from npm registry (weekly average, February 2026). Bundle sizes from bundlephobia. Performance measurements are approximations based on community benchmarks with 100K uniform string items.