Skip to main content

@tanstack/react-virtual vs react-window vs react-virtuoso: List Virtualization in 2026

·PkgPulse Team

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.


PackageWeekly DownloadsLast Major VersionBundle Size
react-window~1.9M1.8 (2019)~6KB
react-virtuoso~2.1M4.x (2024)~17KB
@tanstack/react-virtual~1.3M3.x (2024)~5KB
@tanstack/virtual-core~1.3M3.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 — GroupedVirtuoso component
  • 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 headers
  • ScrollSeekPlaceholder — show placeholder during fast scroll

Feature Comparison

Feature@tanstack/react-virtualreact-virtuosoreact-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:

LibraryInitial RenderScroll FPSMemory (DOM nodes)
No virtualization~2000ms~15 FPS100,000
react-window~8ms60 FPS~20
@tanstack/react-virtual~6ms60 FPS~20 + overscan
react-virtuoso~12ms60 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.

Compare virtual list packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.