react-compose-with-tanstack-virtual

star 28.1k

`@tanstack/react-table` v9 does NOT include virtualization — pair with `@tanstack/react-virtual`. Standard row-virtualization pattern: get the row array from `table.getRowModel().rows`, feed `rows.length` to `useVirtualizer({ count, estimateSize, getScrollElement, ... })` in the DEEPEST possible component (a `TableBody`, NOT `App`), iterate `rowVirtualizer.getVirtualItems()` instead of `rows.map`, absolute-position each row with `transform: translateY(virtualRow.start)px`, and render `<tbody>` as a CSS grid with a fixed total height. Column virtualization uses `horizontal: true` plus padding-left/right placeholder cells. An experimental ref-mutation variant skips React reconciliation for ~10% extra perf but the standard pattern is the default.

TanStack By TanStack schedule Updated 6/12/2026

name: react/compose-with-tanstack-virtual description: > @tanstack/react-table v9 does NOT include virtualization — pair with @tanstack/react-virtual. Standard row-virtualization pattern: get the row array from table.getRowModel().rows, feed rows.length to useVirtualizer({ count, estimateSize, getScrollElement, ... }) in the DEEPEST possible component (a TableBody, NOT App), iterate rowVirtualizer.getVirtualItems() instead of rows.map, absolute-position each row with transform: translateY(virtualRow.start)px, and render <tbody> as a CSS grid with a fixed total height. Column virtualization uses horizontal: true plus padding-left/right placeholder cells. An experimental ref-mutation variant skips React reconciliation for ~10% extra perf but the standard pattern is the default. type: composition library: tanstack-table framework: react library_version: '9.0.0-alpha.48' requires: - react/table-state - row-expanding sources: - TanStack/table:docs/guide/virtualization.md - TanStack/table:examples/react/virtualized-rows/src/main.tsx - TanStack/table:examples/react/virtualized-columns/src/main.tsx - TanStack/table:examples/react/virtualized-infinite-scrolling/src/main.tsx - TanStack/table:examples/react/virtualized-rows-experimental/src/main.tsx

This skill builds on tanstack-table/state-management and tanstack-table/react/table-state. Read those first — the table's row model is what feeds the virtualizer.

Why this skill exists

TanStack Table renders every row in its getRowModel().rows array. For 50 rows that's fine; for 50k or 500k it crashes the browser. @tanstack/react-virtual only renders the rows that fit inside the scroll container, recycling DOM nodes as the user scrolls.

Setup

pnpm add @tanstack/react-table @tanstack/react-virtual

The two pieces:

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

Core Pattern — row virtualization (standard)

The single most important rule: keep useVirtualizer in the deepest component possible. Any state change in the component that owns the virtualizer re-runs it, blowing away scroll position and measurement cache.

import * as React from 'react'
import {
  useTable,
  tableFeatures,
  columnSizingFeature,
  rowSortingFeature,
  createSortedRowModel,
  sortFns,
  createColumnHelper,
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import type { ReactTable, Row } from '@tanstack/react-table'
import type { VirtualItem, Virtualizer } from '@tanstack/react-virtual'

const features = tableFeatures({
  columnSizingFeature,
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns,
})
const columnHelper = createColumnHelper<typeof features, Person>()

const columns = columnHelper.columns([
  columnHelper.accessor('id', { header: 'ID', size: 60 }),
  columnHelper.accessor('firstName', { cell: (info) => info.getValue() }),
  columnHelper.accessor('lastName', {
    id: 'lastName',
    cell: (info) => info.getValue(),
  }),
])

function App() {
  // 1) Scroll container ref + table at App level.
  const tableContainerRef = React.useRef<HTMLDivElement>(null)
  const [data] = React.useState(() => makeData(200_000))

  const table = useTable({
    features,
    columns,
    data,
  })

  return (
    <div
      ref={tableContainerRef}
      style={{ overflow: 'auto', position: 'relative', height: 800 }}
    >
      {/* 2) display: grid — required for absolute positioning + dynamic heights */}
      <table style={{ display: 'grid' }}>
        <thead
          style={{
            display: 'grid',
            position: 'sticky',
            top: 0,
            zIndex: 1,
            height: 34,
          }}
        >
          {table.getHeaderGroups().map((hg) => (
            <tr
              key={hg.id}
              style={{ display: 'flex', height: 34, width: '100%' }}
            >
              {hg.headers.map((h) => (
                <th
                  key={h.id}
                  style={{
                    display: 'flex',
                    alignItems: 'center',
                    width: h.getSize(),
                  }}
                >
                  <div onClick={h.column.getToggleSortingHandler()}>
                    <table.FlexRender header={h} />
                  </div>
                </th>
              ))}
            </tr>
          ))}
        </thead>
        {/* 3) Virtualizer lives inside TableBody, NOT here. */}
        <TableBody table={table} tableContainerRef={tableContainerRef} />
      </table>
    </div>
  )
}

interface TableBodyProps {
  table: ReactTable<typeof features, Person>
  tableContainerRef: React.RefObject<HTMLDivElement | null>
}

function TableBody({ table, tableContainerRef }: TableBodyProps) {
  const { rows } = table.getRowModel()

  // 4) useVirtualizer in the deepest body component.
  const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
    count: rows.length,
    estimateSize: () => 33,
    getScrollElement: () => tableContainerRef.current,
    // 5) Skip dynamic measurement on Firefox — it measures border height wrong.
    measureElement:
      typeof window !== 'undefined' &&
      navigator.userAgent.indexOf('Firefox') === -1
        ? (el) => el.getBoundingClientRect().height
        : undefined,
    overscan: 5,
  })

  return (
    <tbody
      style={{
        display: 'grid',
        height: `${rowVirtualizer.getTotalSize()}px`, // total scrollable height
        position: 'relative', // for absolute child rows
      }}
    >
      {rowVirtualizer.getVirtualItems().map((virtualRow) => {
        const row = rows[virtualRow.index]
        return (
          <tr
            key={row.id}
            data-index={virtualRow.index}
            ref={(node) => rowVirtualizer.measureElement(node)}
            style={{
              display: 'flex',
              position: 'absolute',
              transform: `translateY(${virtualRow.start}px)`,
              width: '100%',
            }}
          >
            {row.getAllCells().map((cell) => (
              <td
                key={cell.id}
                style={{ display: 'flex', width: cell.column.getSize() }}
              >
                <table.FlexRender cell={cell} />
              </td>
            ))}
          </tr>
        )
      })}
    </tbody>
  )
}

Source: examples/react/virtualized-rows/src/main.tsx.

Column virtualization and infinite scroll

Column virtualization (horizontal: true + placeholder padding cells) and infinite scroll via useInfiniteQuery + manualSorting: true — see column-virtualization-and-infinite-scroll.md. That file also covers the HIGH-priority manualSorting failure mode and the column-virt padding-cells failure mode.

Experimental ref-mutation variant

examples/react/virtualized-rows-experimental/ and virtualized-columns-experimental/ mutate row style directly via the virtualizer's onChange callback, skipping React reconciliation on scroll. Roughly 10% rendering perf gain in maintainer benchmarks. The pattern is valid but the standard pattern above is the documented default; reach for the experimental version only when measured perf demands it.

Common Mistakes

CRITICAL useVirtualizer in the same component as useTable

Wrong:

function App() {
  const rowVirtualizer = useVirtualizer({
    /* … */
  }) // virtualizer too high
  const table = useTable(opts)
  return <TableBody table={table} virtualizer={rowVirtualizer} />
}

Correct:

function App() {
  const tableContainerRef = React.useRef<HTMLDivElement>(null)
  const table = useTable(opts)
  return (
    <div ref={tableContainerRef}>
      <TableBody table={table} tableContainerRef={tableContainerRef} />
    </div>
  )
}
function TableBody({ table, tableContainerRef }) {
  const rowVirtualizer = useVirtualizer({
    /* … */
  }) // virtualizer deepest
  /* … */
}

Any state change in the component owning the virtualizer re-runs it — losing scroll position and remeasuring every row. Source: examples/react/virtualized-rows/src/main.tsx.

CRITICAL Rendering rows.map directly on a large dataset

Wrong:

<tbody>
  {rows.map((row) => (
    <tr key={row.id}>...</tr>
  ))}
</tbody>
// 200k DOM rows — browser crashes.

Correct:

<tbody
  style={{
    display: 'grid',
    height: `${rowVirtualizer.getTotalSize()}px`,
    position: 'relative',
  }}
>
  {rowVirtualizer.getVirtualItems().map((vr) => {
    const row = rows[vr.index]
    return (
      <tr
        style={{
          position: 'absolute',
          transform: `translateY(${vr.start}px)` /* … */,
        }}
      >
        {/* … */}
      </tr>
    )
  })}
</tbody>

Use getVirtualItems() so only the visible window renders. Source: examples/react/virtualized-rows/src/main.tsx.

CRITICAL Missing display: grid + absolute positioning

Wrong:

<tbody>
  {rowVirtualizer.getVirtualItems().map((vr) => (
    <tr key={vr.key}>{/* no transform, no absolute */}</tr>
  ))}
</tbody>

Correct:

<tbody
  style={{
    display: 'grid',
    height: `${rowVirtualizer.getTotalSize()}px`,
    position: 'relative',
  }}
>
  {rowVirtualizer.getVirtualItems().map((vr) => (
    <tr
      style={{
        display: 'flex',
        position: 'absolute',
        transform: `translateY(${vr.start}px)`,
        width: '100%',
      }}
    >
      {/* … */}
    </tr>
  ))}
</tbody>

The semantic <table> layout collides with absolute positioning. CSS grid lets the rows position themselves freely while keeping semantic tags. Without transform: translateY(start)px all rows render at top: 0. Source: examples/react/virtualized-rows/src/main.tsx.

HIGH Using measureElement on Firefox

Wrong:

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  estimateSize: () => 33,
  getScrollElement: () => ref.current,
  measureElement: (el) => el.getBoundingClientRect().height, // jitters in Firefox
})

Correct:

const rowVirtualizer = useVirtualizer({
  count: rows.length,
  estimateSize: () => 33,
  getScrollElement: () => ref.current,
  measureElement:
    typeof window !== 'undefined' &&
    navigator.userAgent.indexOf('Firefox') === -1
      ? (el) => el.getBoundingClientRect().height
      : undefined,
})

Firefox returns inconsistent row heights for table rows, causing flicker. Guard the option. Source: examples/react/virtualized-rows/src/main.tsx.

HIGH Storing the ref instead of using the callback-ref form

Wrong:

const rowRef = React.useRef(null)
<tr ref={rowRef} /*…*/ />
// rowVirtualizer can't remeasure when row content changes height

Correct:

<tr ref={(node) => rowVirtualizer.measureElement(node)} /*…*/ />

The pattern is a ref callback that calls measureElement(node) — passing a stored ref means the virtualizer never gets a chance to remeasure. Source: examples/react/virtualized-rows/src/main.tsx.

For HIGH-priority failure modes specific to column virtualization (missing padding placeholders) and infinite scroll (manualSorting requirement), see column-virtualization-and-infinite-scroll.md.

See Also

  • tanstack-table/react/production-readiness — keep virtualizers in deepest components.
  • tanstack-table/react/compose-with-tanstack-queryuseInfiniteQuery integration.
  • tanstack-table/react/table-state — the row model API the virtualizer reads from.

References

Install via CLI
npx skills add https://github.com/TanStack/table --skill react-compose-with-tanstack-virtual
Repository Details
star Stars 28,097
call_split Forks 3,526
navigation Branch main
article Path SKILL.md
More from Creator