Capítulo 46 de 54

Chapter 46: Virtualization (React) Guide

Core Idea

Virtualization is not a TanStack Table feature at all — it's a separate concern handled by @tanstack/react-virtual, which decides which row/column indexes to render for the current scroll position while the table continues to own row models, columns, sizing, sorting, and filtering; your renderer's only job is mapping virtual indexes back to table.getRowModel().rows / table.getVisibleLeafColumns().

Key Concepts

  • Division of labor: TanStack Table builds row models/columns/headers/cells/state; TanStack Virtual computes visible index ranges from scroll position; your render code maps one to the other. Table setup is completely unchanged by adding virtualization — same tableFeatures()/useTable() as any other table.
  • When to use it: very large row and/or column counts, to keep the DOM small (render only what's in the scroll viewport + a small overscan buffer). Not a substitute for server-side pagination/filtering/sorting — virtualized data still has to exist in the browser; if the full dataset can't be loaded client-side, that's a server-side-processing problem, virtualization doesn't solve it. Skip it for small tables — plain rendering is simpler.
  • The basic pattern (rows): fixed-height scroll container → useVirtualizer({ count: rows.length, getScrollElement, estimateSize, overscan }) → give the <tbody> height: rowVirtualizer.getTotalSize() + position: relative → render only rowVirtualizer.getVirtualItems(), each row absolutely positioned via transform: translateY(virtualRow.start).
  • Column virtualization uses a different strategy: instead of absolutely positioning each column, add left/right spacer cells sized from virtualColumns[0]?.start and columnVirtualizer.getTotalSize() - lastVirtualColumn.end — this preserves scroll width while keeping row-like table markup intact (which dynamic row-height measurement depends on). Configure with horizontal: true and estimateSize: (i) => visibleColumns[i].getSize().
  • Rows + columns together: two independent virtualizers — row virtualizer owns vertical position/total body height, column virtualizer owns horizontal header/cell rendering + left/right spacers. Always map virtual indexes against the current rows/visibleColumns lists — recompute after any sorting/filtering/pagination/grouping/visibility change, never reuse stale index mappings.
  • Infinite scrolling: fetch pages, flatten into data, virtualize the loaded rows, and trigger the next fetch when scroll position nears the bottom (scrollHeight - scrollTop - clientHeight < threshold). If sorting is server-driven, use manualSorting so a full re-sort re-fetches from the top — and call rowVirtualizer.scrollToIndex(0) when the sort changes and the loaded dataset gets replaced.
  • Dynamic row heights: estimateSize is just the virtualizer's initial guess; attach ref={(node) => rowVirtualizer.measureElement(node)} and data-index={virtualRow.index} on each row to let the virtualizer refine actual heights after render. Skip measureElement entirely when every row has a known fixed height — it's pure overhead in that case. More overscan reduces blank-region flashes while measurements settle, at the cost of more DOM nodes.
  • Layout requirement: dynamic-height virtualization generally needs table { display: grid }, thead { display: grid; position: sticky; top: 0 }, tr { display: flex } — native table layout doesn't cooperate with independently-positioned, variable-height virtual rows.
  • Performance discipline: keep virtualizer instances near the components that render virtualized items; keep row/column/data references stable; choose overscan deliberately (blanking vs. DOM node count trade-off); avoid expensive cell renderers at large scale; measure with production builds (React dev mode is meaningfully slower); prefer fixed row sizes when the UI allows it; use column.getSize()/header.getSize()/cell.column.getSize() consistently for column virtualization sizing.
  • Experimental React examples (only after profiling shows scroll-time React render is the bottleneck): use TanStack Virtual's onChange callback to imperatively mutate DOM styles (rowRef.style.transform, container CSS custom properties, body height) outside React's render flow entirely, paired with a custom React.memo comparator to freeze row/column components during scroll. Scope imperative DOM writes strictly to scroll-position-only styling (transform, sizes, spacer widths) — never route business/table state, data, sorting, or filtering through this path.

Code Examples

const rows = table.getRowModel().rows
const rowVirtualizer = useVirtualizer({
  count: rows.length, getScrollElement: () => containerRef.current, estimateSize: () => 33, overscan: 5,
})

<tbody style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
  {rowVirtualizer.getVirtualItems().map((vr) => {
    const row = rows[vr.index]
    return (
      <tr key={row.id} style={{ position: 'absolute', transform: `translateY(${vr.start}px)`, width: '100%' }}>
        {row.getVisibleCells().map((cell) => <td key={cell.id}>{/* ... */}</td>)}
      </tr>
    )
  })}
</tbody>
  • What it demonstrates: the canonical row-virtualization skeleton — total height on the container, absolute positioning per row via virtualRow.start, mapping virtualRow.index back to the table's real row array.

Key Takeaways

  1. TanStack Table doesn't ship virtualization — it's always @tanstack/react-virtual (or another library) layered on top, reading table.getRowModel().rows/getVisibleLeafColumns().
  2. Virtualization solves DOM size, not dataset size — pair it with server-side operations when the full dataset can't live in the browser, don't expect it to replace pagination.
  3. Column virtualization uses spacer cells, not absolute positioning, specifically to stay compatible with dynamic row-height measurement.
  4. Always derive virtual index mappings from the current table row/column lists — stale mappings after a sort/filter/visibility change are the most common bug.

Connects To

  • Client-Side vs Server-Side Guide: the "rendering is a separate decision from data processing" distinction this whole guide depends on.
  • Row Models Guide: table.getRowModel().rows, the source list every virtualizer reads from.
  • Column Sizing / Column Resizing Guides: getSize() APIs used to size virtualized columns.