Back to Blog

React Virtual Table: Rendering 100k Rows with TanStack Virtual, Dynamic Heights, and Sticky Headers

Build a high-performance virtual table in React for 100,000+ rows using TanStack Virtual. Covers fixed and dynamic row heights, sticky header, horizontal scroll, row selection, column resizing, and integration with TanStack Table for sorting and filtering.

Viprasol Tech Team
June 13, 2027
12 min read

Rendering 100,000 rows in a DOM table kills the browser. Each <tr> and <td> is a DOM node β€” a 100k-row table with 10 columns creates 1 million DOM nodes before any layout or paint happens. TanStack Virtual solves this by rendering only the rows that are visible in the viewport (typically 20–40 rows), recycling them as the user scrolls.

Setup

npm install @tanstack/react-virtual @tanstack/react-table

Fixed-Height Virtual Table

// components/virtual-table/virtual-table.tsx
"use client";

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  flexRender,
  type ColumnDef,
  type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useState, useMemo } from "react";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";

interface VirtualTableProps<TData> {
  data:     TData[];
  columns:  ColumnDef<TData>[];
  rowHeight?: number;  // Fixed row height in px (default: 40)
}

export function VirtualTable<TData>({
  data,
  columns,
  rowHeight = 40,
}: VirtualTableProps<TData>) {
  const [sorting,   setSorting]   = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState("");

  const table = useReactTable({
    data,
    columns,
    state:             { sorting, globalFilter },
    onSortingChange:   setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel:      getCoreRowModel(),
    getSortedRowModel:    getSortedRowModel(),
    getFilteredRowModel:  getFilteredRowModel(),
  });

  const { rows } = table.getRowModel();

  // Ref on the scrollable container (not the table itself)
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count:         rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize:  () => rowHeight,    // Fixed height
    overscan:      5,                  // Render 5 extra rows above/below viewport
  });

  const virtualRows  = virtualizer.getVirtualItems();
  const totalHeight  = virtualizer.getTotalSize();   // Total scrollable height

  // Padding to position visible rows correctly within the total height
  const paddingTop    = virtualRows.length > 0 ? virtualRows[0].start    : 0;
  const paddingBottom = virtualRows.length > 0
    ? totalHeight - virtualRows[virtualRows.length - 1].end
    : 0;

  return (
    <div className="flex flex-col h-full border border-gray-200 rounded-xl overflow-hidden">
      {/* Search bar */}
      <div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
        <input
          type="text"
          value={globalFilter}
          onChange={(e) => setGlobalFilter(e.target.value)}
          placeholder={`Search ${data.length.toLocaleString()} rows…`}
          className="w-72 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>

      {/* Sticky header */}
      <div className="overflow-x-auto">
        <table className="w-full border-collapse table-fixed">
          <thead className="sticky top-0 bg-white z-10 border-b border-gray-200">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    style={{ width: header.getSize() }}
                    className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider select-none"
                    onClick={header.column.getToggleSortingHandler()}
                  >
                    <div className="flex items-center gap-1.5">
                      {flexRender(header.column.columnDef.header, header.getContext())}
                      {header.column.getCanSort() && (
                        <span className="text-gray-400">
                          {header.column.getIsSorted() === "asc"  ? <ArrowUp   className="w-3.5 h-3.5" /> :
                           header.column.getIsSorted() === "desc" ? <ArrowDown className="w-3.5 h-3.5" /> :
                           <ArrowUpDown className="w-3.5 h-3.5" />}
                        </span>
                      )}
                    </div>
                  </th>
                ))}
              </tr>
            ))}
          </thead>
        </table>
      </div>

      {/* Virtualized body */}
      <div ref={parentRef} className="flex-1 overflow-auto">
        <table className="w-full border-collapse table-fixed">
          <tbody>
            {paddingTop > 0 && (
              <tr><td style={{ height: `${paddingTop}px` }} colSpan={columns.length} /></tr>
            )}
            {virtualRows.map((virtualRow) => {
              const row = rows[virtualRow.index];
              return (
                <tr
                  key={row.id}
                  data-index={virtualRow.index}
                  className="border-b border-gray-100 hover:bg-blue-50/50 transition-colors"
                >
                  {row.getVisibleCells().map((cell) => (
                    <td
                      key={cell.id}
                      style={{ width: cell.column.getSize() }}
                      className="px-4 py-0 text-sm text-gray-700 overflow-hidden text-ellipsis whitespace-nowrap"
                    >
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </td>
                  ))}
                </tr>
              );
            })}
            {paddingBottom > 0 && (
              <tr><td style={{ height: `${paddingBottom}px` }} colSpan={columns.length} /></tr>
            )}
          </tbody>
        </table>
      </div>

      {/* Footer: row count */}
      <div className="px-4 py-2 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
        {rows.length.toLocaleString()} rows{" "}
        {rows.length !== data.length && `(filtered from ${data.length.toLocaleString()})`}
      </div>
    </div>
  );
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Dynamic Row Heights

// When rows have variable heights (e.g., multi-line descriptions)
// components/virtual-table/variable-height-table.tsx

import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useCallback } from "react";

export function VariableHeightTable<TData>({ rows }: { rows: TData[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const rowSizeMap = useRef<Map<number, number>>(new Map());

  // Called by rows to report their actual measured height
  const measureRow = useCallback((index: number, el: HTMLElement | null) => {
    if (!el) return;
    const height = el.getBoundingClientRect().height;
    if (rowSizeMap.current.get(index) !== height) {
      rowSizeMap.current.set(index, height);
      virtualizer.measure(); // Tell virtualizer to recalculate
    }
  }, []);

  const virtualizer = useVirtualizer({
    count:            rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize:     (index) => rowSizeMap.current.get(index) ?? 60, // Initial estimate
    measureElement:   (el) => el.getBoundingClientRect().height,       // Measure actual DOM element
    overscan:         3,
  });

  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            data-index={virtualItem.index}
            ref={(el) => virtualizer.measureElement(el)}  // Auto-measure
            style={{
              position:  "absolute",
              top:       0,
              left:      0,
              width:     "100%",
              transform: `translateY(${virtualItem.start}px)`,  // Position via transform
            }}
            className="border-b border-gray-100 px-4 py-3"
          >
            {/* Row content β€” height is determined by content */}
            <RowContent item={rows[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Row Selection

// Add row selection to the fixed-height virtual table

import { type RowSelectionState } from "@tanstack/react-table";

// In VirtualTable:
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

const selectionColumn: ColumnDef<TData> = {
  id:     "select",
  size:   40,
  header: ({ table }) => (
    <input
      type="checkbox"
      checked={table.getIsAllRowsSelected()}
      indeterminate={table.getIsSomeRowsSelected()}
      onChange={table.getToggleAllRowsSelectedHandler()}
      className="rounded border-gray-300"
    />
  ),
  cell: ({ row }) => (
    <input
      type="checkbox"
      checked={row.getIsSelected()}
      onChange={row.getToggleSelectedHandler()}
      className="rounded border-gray-300"
      onClick={(e) => e.stopPropagation()} // Don't trigger row click
    />
  ),
};

// Combine with other columns:
// const allColumns = [selectionColumn, ...columns];

// Get selected rows:
// const selectedRows = table.getSelectedRowModel().rows.map(r => r.original);

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Example: 100k Row Table

// Demo: generate 100,000 rows and render without freezing
// pages/demo/large-table.tsx
"use client";

import { useMemo } from "react";
import { VirtualTable } from "@/components/virtual-table/virtual-table";
import type { ColumnDef } from "@tanstack/react-table";

interface Row {
  id:        number;
  name:      string;
  email:     string;
  plan:      string;
  mrr:       number;
  createdAt: string;
}

export default function LargeTableDemo() {
  // Generate 100k rows (in production, these come from an API)
  const data = useMemo<Row[]>(() => {
    return Array.from({ length: 100_000 }, (_, i) => ({
      id:        i + 1,
      name:      `Workspace ${i + 1}`,
      email:     `user${i + 1}@example.com`,
      plan:      ["free", "starter", "growth", "enterprise"][i % 4],
      mrr:       Math.round(Math.random() * 1000 * 100) / 100,
      createdAt: new Date(Date.now() - i * 86400_000).toLocaleDateString(),
    }));
  }, []);

  const columns = useMemo<ColumnDef<Row>[]>(() => [
    { accessorKey: "id",        header: "ID",         size: 80,  enableSorting: true },
    { accessorKey: "name",      header: "Name",        size: 200, enableSorting: true },
    { accessorKey: "email",     header: "Email",       size: 250, enableSorting: true },
    { accessorKey: "plan",      header: "Plan",        size: 100, enableSorting: true },
    {
      accessorKey: "mrr",
      header:      "MRR",
      size:        120,
      enableSorting: true,
      cell: ({ getValue }) => `$${(getValue() as number).toFixed(2)}`,
    },
    { accessorKey: "createdAt", header: "Created",     size: 150, enableSorting: true },
  ], []);

  return (
    <div className="h-screen p-6 flex flex-col gap-4">
      <h1 className="text-2xl font-bold">
        100,000 Row Virtual Table
      </h1>
      <div className="flex-1 min-h-0">
        <VirtualTable data={data} columns={columns} rowHeight={40} />
      </div>
    </div>
  );
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Basic virtual table (fixed height)1 dev1–2 days$400–800
+ Dynamic row heights1 dev1 day$300–600
+ Row selection + column resize1 dev1 day$300–600
Full data grid (sort + filter + virtual)1 dev3–5 days$1,200–2,500

See Also


Working With Viprasol

Virtual tables require getting the layout right: the header must be sticky (sticky top-0 z-10) outside the scrollable body div, the body needs a separate ref for the virtualizer's getScrollElement, and padding rows must be inserted above and below the virtual items to maintain correct scroll position. For dynamic heights, the measureElement callback tells the virtualizer the true DOM height after render.

What we deliver:

  • VirtualTable<TData>: generic component with useReactTable + useVirtualizer
  • estimateSize: () => rowHeight for fixed heights, measureElement for dynamic
  • Padding rows (paddingTop + paddingBottom) to maintain scroll correctness
  • Sticky header: separate <div> with overflow-x-auto + table-fixed outside scroll container
  • Global filter + per-column sort with ArrowUp/ArrowDown/ArrowUpDown icons
  • VariableHeightTable: translateY positioning + ref={(el) => virtualizer.measureElement(el)}
  • Row selection column with header checkbox (indeterminate state for partial selection)
  • 100k row demo: Array.from({ length: 100_000 }) with useMemo

Talk to our team about your data-heavy UI requirements β†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.