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.
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
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic virtual table (fixed height) | 1 dev | 1β2 days | $400β800 |
| + Dynamic row heights | 1 dev | 1 day | $300β600 |
| + Row selection + column resize | 1 dev | 1 day | $300β600 |
| Full data grid (sort + filter + virtual) | 1 dev | 3β5 days | $1,200β2,500 |
See Also
- React Data Tables
- React Performance Optimization
- Next.js INP Optimization
- React Keyboard Shortcuts
- SaaS Usage Analytics
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 withuseReactTable+useVirtualizerestimateSize: () => rowHeightfor fixed heights,measureElementfor dynamic- Padding rows (paddingTop + paddingBottom) to maintain scroll correctness
- Sticky header: separate
<div>withoverflow-x-auto+table-fixedoutside scroll container - Global filter + per-column sort with
ArrowUp/ArrowDown/ArrowUpDownicons VariableHeightTable:translateYpositioning +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.
About the Author
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.
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
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.