Table Virtualization in React: Boosting Performance for Large Data Sets

Ritik SachanRitik Sachan
4 min read

Building lightning-fast user interfaces is core to a great React experience. But what happens when your tables grow to thousands or even millions of rows? That’s where table virtualization shines. In this article, we’ll unpack table virtualization in React, why you need it, and how to implement it for scalable, high-performance applications.

What Is Table Virtualization?

Table virtualization is a technique that renders only the visible rows in a table, along with a buffer, rather than all rows at once. Instead of bogging down the browser with thousands of DOM nodes, you work with just what the user can see—resulting in dramatic performance improvements.

Why Does Table Virtualization Matter?

Rendering extensive tables is a notorious pain point:

  • Browsers quickly slow down with thousands of DOM elements.

  • User interactions (scrolling, updates) become sluggish.

  • Consumes excessive memory and CPU.

Virtualization solves this by keeping the DOM lean and responsive, even for massive data sets.

How Virtualization Works

Here’s the core idea:

  • Calculate which rows are currently visible based on scroll position.

  • Only render those rows, along with a configurable “overscan” buffer for smoothness.

  • As the user scrolls, old rows unmount and new ones mount on the fly.

Several robust libraries help you implement virtualization:

LibraryFeaturesTypical Use Case
react-windowLightweight, fast, supports lists and gridsLarge, simple tables
react-virtualizedFeature-rich, cell measuring, windowing, infinite loadingComplex, high-feature tables
TanStack VirtualLow-level virtualizer, can be plugged into any UICustom or advanced scenarios

Implementing From Scratch: A Step-by-Step Example

import React, { useState, useRef } from "react";
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

interface VirtualizedTableProps<T extends object> {
  data: T[];
  columns: ColumnDef<T, unknown>[];
  rowHeight?: number;
  height?: number;
}

export function VirtualizedTanStackTable<T extends object>({
  data,
  columns,
  rowHeight = 35,
  height = 400,
}: VirtualizedTableProps<T>) {

// div element ref which contians the virtualized table
  const containerRef = useRef<HTMLDivElement>(null);
// state to handle scroll position
  const [scrollTop, setScrollTop] = useState(0);

// i am using tanstack table here.You can use any table.
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

// get total no of rows
  const totalRows = table.getRowModel().rows.length;

  // Calculate visible range
    // here magic happpens - we always set the startIndex according to the scroll position 
  const startIndex = Math.floor(scrollTop / rowHeight);
    // endIndex will always be equal to startIndex + buffer. we cap that to totalRows.
  const endIndex = Math.min(
    totalRows,
    startIndex + Math.ceil(height / rowHeight) + 5, // buffer rows
  );

// here we are taking only rows which will be rendered in the DOM
  const visibleRows = table.getRowModel().rows.slice(startIndex, endIndex);

  return (
    <> 
    {/* Parent container */}
      <div
        style={{
          display: "grid",
          gridTemplateColumns: `repeat(${columns.length}, 1fr)`,
          fontWeight: "bold",
          borderBottom: "1px solid #ddd",
          background: "#f9f9f9",
        }}
      >
    {/* Table Header */}
        {table.getHeaderGroups().map((headerGroup) =>
          headerGroup.headers.map((header) => (
            <div key={header.id} style={{ padding: "4px 8px" }}>
              {flexRender(header.column.columnDef.header, header.getContext())}
            </div>
          )),
        )}
      </div>
    {/* data rows */}
    {/* sets the current position of scroll */}
      <div
        ref={containerRef}
        style={{
          height: `${height}px`,
          overflowY: "auto",
          position: "relative",
          border: "1px solid #ccc",
        }}
        onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
      >
        {/* This div simulates full table height so scrollbar behaves normally */}
        <div
          style={{ height: `${totalRows * rowHeight}px`, position: "relative" }}
        >
          {/* Shift the visible rows into their correct scroll position */}
          <div style={{ transform: `translateY(${startIndex * rowHeight}px)` }}>
            {/* Render table header */}

            {/* Render visible rows */}
            {visibleRows.map((row) => (
              <div
                key={row.id}
                style={{
                  display: "grid",
                  gridTemplateColumns: `repeat(${columns.length}, 1fr)`,
                  height: `${rowHeight}px`,
                  borderBottom: "1px solid #eee",
                  alignItems: "center",
                  padding: "0 8px",
                }}
              >
                {row.getVisibleCells().map((cell) => (
                  <div key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </div>
                ))}
              </div>
            ))}
          </div>
        </div>
      </div>
    </>
  );
}

Implement using React-Virtual : A Step-by-Step Example -

import React, { useRef } from "react";
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";

interface VirtualizedTableProps<T extends object> {
  data: T[];
  columns: ColumnDef<T, unknown>[];
  rowHeight?: number;
  height?: number;
}

export function VirtualizedTanStackTableVirtual<T extends object>({
  data,
  columns,
  rowHeight = 35,
  height = 400,
}: VirtualizedTableProps<T>) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  const parentRef = useRef<HTMLDivElement>(null);

  // Virtualizer handles visible rows math
  const rowVirtualizer = useVirtualizer({
    count: table.getRowModel().rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => rowHeight,
    overscan: 1, // render extra rows above/below for smoothness
  });

  const virtualItems = rowVirtualizer.getVirtualItems();
  const totalHeight = rowVirtualizer.getTotalSize();

  return (
    <div
      ref={parentRef}
      style={{
        height: `${height}px`,
        overflowY: "auto",
        position: "relative",
        border: "1px solid #ccc",
      }}
    >
      {/* Sticky Header */}
      <div
        style={{
          position: "sticky",
          top: 0,
          zIndex: 1,
          background: "#f9f9f9",
          borderBottom: "1px solid #ddd",
          display: "grid",
          gridTemplateColumns: `repeat(${columns.length}, 1fr)`,
          fontWeight: "bold",
        }}
      >
        {table.getHeaderGroups().map((headerGroup) =>
          headerGroup.headers.map((header) => (
            <div key={header.id} style={{ padding: "4px 8px" }}>
              {flexRender(header.column.columnDef.header, header.getContext())}
            </div>
          ))
        )}
      </div>

      {/* Virtualized body */}
      <div style={{ height: `${totalHeight}px`, position: "relative" }}>
        {virtualItems.map((virtualRow) => {
          const row = table.getRowModel().rows[virtualRow.index];
          return (
            <div
              key={row?.id}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                transform: `translateY(${virtualRow.start}px)`,
                display: "grid",
                gridTemplateColumns: `repeat(${columns.length}, 1fr)`,
                height: `${rowHeight}px`,
                borderBottom: "1px solid #eee",
                alignItems: "center",
                padding: "0 8px",
              }}
            >
              {row?.getVisibleCells().map((cell) => (
                <div key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </div>
              ))}
            </div>
          );
        })}
      </div>
    </div>
  );
}
0
Subscribe to my newsletter

Read articles from Ritik Sachan directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ritik Sachan
Ritik Sachan