mrkeyoor.com_
Thu 06 Aug 07:39 UTC
npmWeb Frontendupdated 06 Aug 2026

@tanstack/react-virtual

@tanstack/react-virtual is the React binding for TanStack Virtual, a headless list virtualizer. You give the useVirtualizer hook a count, a function that returns your scroll container, and an estimated item size. It hands back a list of visible items with index, key, start offset, and size, plus a total size for the spacer. It renders nothing itself: no components, no CSS, no opinions about your markup. You keep writing your own divs and styles, and the hook just tells you which slice of 100,000 rows is currently on screen.

Verdict

The best headless virtualizer in React right now: small, actively developed, and it never touches your markup. Budget half a day for the CSS contract and measurement quirks, and reach for react-virtuoso instead if you want the list handed to you finished.

API stability4/5The v3 core (useVirtualizer, getVirtualItems, measureElement) has been stable since 2023 and new capabilities arrive as opt-in options like anchorTo, directDomUpdates, and takeSnapshot; the v2 to v3 jump was a full rewrite, and the react adapter versions independently from virtual-core, which surprises people reading changelogs.
Docs4/5tanstack.com/virtual documents every option and instance method with typed signatures and runnable examples for fixed, dynamic, grid, masonry, and window lists; what it lacks is a plain 'here is the required CSS and why your list is blank' page, which is the single most common failure.
Maintenance5/5Last push 3 August 2026, 3.14.9 published 28 July 2026, part of the TanStack org with a paid maintenance team behind it; 77 open issues and 31 open PRs on 7,040 stars is normal traffic for a library at 21M weekly downloads.
Ecosystem5/521.1M weekly downloads, official adapters for React, Solid, Svelte, Vue, Lit, Angular, and Marko off one core, and it is the virtualizer the TanStack Table docs pair with; nearly every React data-grid tutorial written since 2023 uses it.

Use it if

  • You are rendering a list, table, or grid with thousands of rows and the browser is choking on DOM nodes rather than on your data fetching
  • Your rows have unpredictable heights (chat messages, comment threads, wrapped text) and you need real measurement instead of a hard-coded row height
  • You already own the markup: a design-system table, a CSS grid, a component with custom hover and focus behavior that a prebuilt list component would fight you on
  • You need window scrolling rather than an inner scroll container, which useWindowVirtualizer handles with a scrollMargin offset
  • You are pairing it with TanStack Table or an infinite query and want one virtualizer instance you can drive from your own state
Skip it if

Setup reality

npm install @tanstack/react-virtual pulls in @tanstack/virtual-core and declares React 16.8 through 19 as a peer, so it installs clean on most trees. The friction is not the install, it is the CSS contract you have to honor by hand. Your scroll container needs an explicit height and overflow: auto or nothing renders, because getScrollElement measures zero. Your inner spacer needs position: relative and a height equal to getTotalSize(). Every item needs position: absolute plus a transform or a top offset. If you use dynamic measurement, the same element that gets ref={virtualizer.measureElement} must also carry data-index, and you must not set its height in style or you have pinned it to your estimate forever. React 19 users often see a flushSync warning during scroll until they pass useFlushSync: false. Add overflow-anchor: none to the scroll container or Chrome's scroll anchoring fights the virtualizer on variable heights.

Patterns

Virtualize a list of fixed-height rowsfixed-size-list

import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'

function Rows({ items }: { items: Array<string> }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
    overscan: 5,
  })

  return (
    <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          width: '100%',
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((row) => (
          <div
            key={row.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: row.size,
              transform: `translateY(${row.start}px)`,
            }}
          >
            {items[row.index]}
          </div>
        ))}
      </div>
    </div>
  )
}

The three CSS requirements are non-negotiable: a fixed height plus overflow on the scroll parent, position: relative plus getTotalSize() on the spacer, position: absolute on every item. Miss any one and you get a blank list or one 100,000-row-tall page.

Measure rows whose height you cannot predictdynamic-row-heights

const virtualizer = useVirtualizer({
  count: messages.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 60,
})

{virtualizer.getVirtualItems().map((item) => (
  <div
    key={item.key}
    data-index={item.index}
    ref={virtualizer.measureElement}
    style={{
      position: 'absolute',
      top: 0,
      left: 0,
      width: '100%',
      transform: `translateY(${item.start}px)`,
    }}
  >
    {messages[item.index].body}
  </div>
))}

data-index must sit on the exact element holding the measureElement ref, and that element must not declare a height. Estimate on the large side: too-small estimates make the scrollbar shrink visibly as the user scrolls.

Virtualize against the page scroll instead of a containerwindow-scrolling

import { useWindowVirtualizer } from '@tanstack/react-virtual'

const listRef = useRef<HTMLDivElement>(null)

const virtualizer = useWindowVirtualizer({
  count: posts.length,
  estimateSize: () => 240,
  overscan: 3,
  scrollMargin: listRef.current?.offsetTop ?? 0,
})

<div ref={listRef} style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
  {virtualizer.getVirtualItems().map((item) => (
    <div
      key={item.key}
      data-index={item.index}
      ref={virtualizer.measureElement}
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
      }}
    >
      {posts[item.index].title}
    </div>
  ))}
</div>

scrollMargin is the distance from the top of the page to the top of your list, and you must subtract it again in the transform. Forget the subtraction and every item sits one header-height too low.

Virtualize a horizontal striphorizontal-list

const colVirtualizer = useVirtualizer({
  horizontal: true,
  count: columns.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 120,
  overscan: 3,
})

<div ref={parentRef} style={{ width: 600, height: 200, overflowX: 'auto' }}>
  <div style={{ width: colVirtualizer.getTotalSize(), height: '100%', position: 'relative' }}>
    {colVirtualizer.getVirtualItems().map((col) => (
      <div
        key={col.key}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          height: '100%',
          width: col.size,
          transform: `translateX(${col.start}px)`,
        }}
      >
        {columns[col.index]}
      </div>
    ))}
  </div>
</div>

With horizontal: true, estimateSize returns widths and getTotalSize() is a width. For right-to-left locales set isRtl: true or the offsets run the wrong way.

Jump to a row programmaticallyscroll-to-index

virtualizer.scrollToIndex(500, { align: 'center' })
virtualizer.scrollToIndex(0, { behavior: 'smooth' })
virtualizer.scrollToOffset(1200, { align: 'start' })
virtualizer.scrollToEnd({ behavior: 'smooth' })
virtualizer.scrollBy(-300)

With dynamic measurement, scrolling far into unmeasured territory lands on estimates and then corrects, so the jump can visibly settle. Calling scrollToIndex inside the same render that sets count often runs before the scroll element exists; do it in an effect.

Give items stable keys so measurements survive reorderingstable-item-keys

import { useCallback } from 'react'

const getItemKey = useCallback((index: number) => rows[index].id, [rows])

const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 48,
  getItemKey,
})

The default key is the array index, so prepending or sorting reassigns every measured height to the wrong row. Memoize getItemKey; an inline arrow re-creates it each render and forces recalculation.

Load the next page when the last row comes into viewinfinite-scroll

const items = virtualizer.getVirtualItems()

useEffect(() => {
  const last = items[items.length - 1]
  if (!last) return
  if (last.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
    fetchNextPage()
  }
}, [items, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])

Depend on the items array, not on virtualizer, or the effect never re-runs. Render a placeholder row for index >= allRows.length so the count can include the incoming page without reading undefined.

Virtualize rows and columns togethergrid-virtualization

const rowV = useVirtualizer({
  count: 10000,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 32,
  overscan: 5,
})
const colV = useVirtualizer({
  horizontal: true,
  count: 200,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 100,
  overscan: 3,
})

<div style={{ height: rowV.getTotalSize(), width: colV.getTotalSize(), position: 'relative' }}>
  {rowV.getVirtualItems().map((row) =>
    colV.getVirtualItems().map((col) => (
      <div
        key={`${row.key}:${col.key}`}
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          height: row.size,
          width: col.size,
          transform: `translate(${col.start}px, ${row.start}px)`,
        }}
      >
        {cell(row.index, col.index)}
      </div>
    )),
  )}
</div>

Two virtualizers share one scroll element, which is supported. Dynamic measurement across both axes is not: measureElement reads a single dimension, so grids want fixed cell sizes.

Keep a section header pinned with rangeExtractorsticky-headers

import { useCallback, useRef } from 'react'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import type { Range } from '@tanstack/react-virtual'

const stickyIndexes = [0, 25, 60]
const activeSticky = useRef(0)

const rangeExtractor = useCallback((range: Range) => {
  activeSticky.current =
    [...stickyIndexes].reverse().find((i) => range.startIndex >= i) ?? 0
  const next = new Set([activeSticky.current, ...defaultRangeExtractor(range)])
  return [...next].sort((a, b) => a - b)
}, [])

const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 36,
  rangeExtractor,
})

rangeExtractor only forces the header to stay rendered; you still write the CSS that pins it (position: sticky with top: 0 and a z-index above the absolutely positioned rows).

Anchor a chat or log view to the endchat-pinned-to-bottom

const virtualizer = useVirtualizer({
  count: messages.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 72,
  getItemKey: (index) => messages[index].id,
  anchorTo: 'end',
  followOnAppend: 'smooth',
  scrollEndThreshold: 24,
})

const showJumpButton = !virtualizer.isAtEnd()

anchorTo: 'end' keeps the viewport stable when older messages are prepended, and followOnAppend only auto-scrolls if the user was already at the bottom. Both need a stable getItemKey: index keys cannot tell a prepend from an append.

Restore measurements and offset after navigationrestore-scroll-position

// leaving the route
sessionStorage.setItem(
  'inbox',
  JSON.stringify({
    snapshot: virtualizer.takeSnapshot(),
    offset: virtualizer.scrollOffset,
  }),
)

// coming back
const saved = JSON.parse(sessionStorage.getItem('inbox') ?? 'null')
const virtualizer = useVirtualizer({
  count: rows.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 48,
  initialMeasurementsCache: saved?.snapshot,
  initialOffset: saved?.offset,
})

The cache is read once on the first measurement pass after mount, so it has to be present in the initial options object. Only rows the user actually scrolled past are in the snapshot; everything else falls back to estimateSize.

Split the list into columns for a masonry layoutmasonry-lanes

const virtualizer = useVirtualizer({
  count: photos.length,
  getScrollElement: () => parentRef.current,
  estimateSize: (i) => photos[i].estimatedHeight,
  lanes: 3,
  laneAssignmentMode: 'measured',
  gap: 12,
})

{virtualizer.getVirtualItems().map((item) => (
  <div
    key={item.key}
    data-index={item.index}
    ref={virtualizer.measureElement}
    style={{
      position: 'absolute',
      top: 0,
      left: `${item.lane * 33.33}%`,
      width: '33.33%',
      transform: `translateY(${item.start}px)`,
    }}
  />
))}

Each item gets a lane index and items go to the shortest lane. The default 'estimate' mode locks lanes from estimateSize before measuring, so bad estimates give lopsided columns; 'measured' fixes that but items can shift once on first measure.

Alternatives

PackageRegistryPick it when
react-virtuosonpmYou want a finished list component with grouping, sticky headers, and reverse scrolling instead of building the markup yourself.
react-windownpmFixed-height rows, a small dependency, and a simple render-prop API are all you need.
virtuanpmYou want automatic item measurement with no manual refs, spacers, or absolute positioning.
@tanstack/virtual-corenpmYou are outside React (vanilla JS, or writing your own framework adapter) and want the same engine.