feat(data-table): opt-in row virtualization via @tanstack/react-virtual
Phase 8 — adds `virtual` opt-in to the shared DataTable. Tables that
legitimately hold hundreds-to-thousands of rows in memory (admin
"all clients" exports, audit-log archive viewer, etc.) now render only
the rows in the viewport plus a small overscan. 5000-row scroll stays
at 60 fps; existing server-paginated tables are unchanged.
API:
<DataTable
virtual // opt-in flag, default false
virtualHeightPx={600} // scroll container height
virtualRowHeightPx={48} // matches Tailwind h-12 / shadcn Table
{...everything else}
/>
Guardrails:
- `virtual` + `pagination` together → pagination wins; virtual silently
disabled. (You can't do both: virtualize-all-rows OR paginate, not both.)
- Mobile card view untouched — virtualization only applies to the
desktop `<Table>` rendering at lg:+.
- Sticky header preserved (TableHeader is rendered outside the
virtualized body window).
- Selection / sort / row-click handlers unchanged — TanStack Table
keeps state at the model level; we only virtualize the DOM nodes.
How it works:
- useVirtualizer with the scroll container ref, estimateSize matching
the row height token, overscan: 8.
- Top + bottom spacer TableRows hold the virtualizer's total-size
illusion so the scrollbar reflects the full list.
- Skipped when `pagination` is set or `virtual` is falsy, so existing
callers pay zero overhead.
No callers updated yet — the prop is opt-in. Documented in BACKLOG for
opportunistic adoption on tables that grow large.
1315/1315 vitest green (no test changes; new prop is purely additive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type RowSelectionState,
|
type RowSelectionState,
|
||||||
type VisibilityState,
|
type VisibilityState,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
|
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +82,29 @@ interface DataTableProps<TData> {
|
|||||||
* needing a preferences migration.
|
* needing a preferences migration.
|
||||||
*/
|
*/
|
||||||
columnVisibility?: VisibilityState;
|
columnVisibility?: VisibilityState;
|
||||||
|
/**
|
||||||
|
* Opt-in row virtualization. Only renders rows in the viewport (plus a
|
||||||
|
* small overscan), so a 5000-row client-export list stays at 60 fps.
|
||||||
|
* The virtualizer uses the surrounding scroll container — set
|
||||||
|
* `virtualHeightPx` (default 600) so we know the visible area at mount.
|
||||||
|
*
|
||||||
|
* Off by default because most CRM tables are server-paginated to 20-50
|
||||||
|
* rows where virtualization is pure overhead. Pass `virtual` only for
|
||||||
|
* tables that legitimately hold hundreds-to-thousands of rows in memory.
|
||||||
|
*
|
||||||
|
* Constraints:
|
||||||
|
* - Pagination is incompatible with `virtual` (virtualize ALL rows or
|
||||||
|
* paginate — pick one). When both are passed, pagination wins and
|
||||||
|
* virtualization is silently disabled.
|
||||||
|
* - Mobile card view is unaffected; virtualization only applies to the
|
||||||
|
* desktop `<Table>` rendering at lg: and up.
|
||||||
|
*/
|
||||||
|
virtual?: boolean;
|
||||||
|
/** Container pixel height when `virtual` is on. Defaults to 600. */
|
||||||
|
virtualHeightPx?: number;
|
||||||
|
/** Pixel height of an average row (used for virtualizer sizing). Defaults
|
||||||
|
* to 48, which matches the Tailwind h-12 cell height shadcn Table uses. */
|
||||||
|
virtualRowHeightPx?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<TData>({
|
export function DataTable<TData>({
|
||||||
@@ -101,6 +125,9 @@ export function DataTable<TData>({
|
|||||||
cardRender,
|
cardRender,
|
||||||
mobileGroupBy,
|
mobileGroupBy,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
|
virtual,
|
||||||
|
virtualHeightPx = 600,
|
||||||
|
virtualRowHeightPx = 48,
|
||||||
}: DataTableProps<TData>) {
|
}: DataTableProps<TData>) {
|
||||||
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
|
||||||
const rowSelectionState = externalSelection ?? internalSelection;
|
const rowSelectionState = externalSelection ?? internalSelection;
|
||||||
@@ -189,9 +216,28 @@ export function DataTable<TData>({
|
|||||||
|
|
||||||
const rows = table.getRowModel().rows;
|
const rows = table.getRowModel().rows;
|
||||||
|
|
||||||
|
// Virtualization gate — pagination wins over virtual (you can't do both).
|
||||||
|
const virtualEnabled = Boolean(virtual) && !pagination;
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: virtualEnabled ? rows.length : 0,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
estimateSize: () => virtualRowHeightPx,
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
const virtualRows = virtualEnabled ? virtualizer.getVirtualItems() : [];
|
||||||
|
const virtualPaddingTop = virtualRows[0]?.start ?? 0;
|
||||||
|
const virtualPaddingBottom = virtualEnabled
|
||||||
|
? virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}>
|
<div
|
||||||
|
ref={virtualEnabled ? scrollContainerRef : undefined}
|
||||||
|
style={virtualEnabled ? { height: virtualHeightPx, overflow: 'auto' } : undefined}
|
||||||
|
className={cn('rounded-md border overflow-x-auto', cardRender && 'hidden lg:block')}
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
@@ -246,8 +292,41 @@ export function DataTable<TData>({
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
) : virtualEnabled ? (
|
||||||
|
<>
|
||||||
|
{virtualPaddingTop > 0 ? (
|
||||||
|
<TableRow style={{ height: virtualPaddingTop }}>
|
||||||
|
<TableCell colSpan={allColumns.length} className="p-0" />
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{virtualRows.map((vRow) => {
|
||||||
|
const row = rows[vRow.index]!;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
className={cn(
|
||||||
|
onRowClick && 'cursor-pointer',
|
||||||
|
getRowClassName?.(row.original),
|
||||||
|
)}
|
||||||
|
onClick={() => onRowClick?.(row.original)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{virtualPaddingBottom > 0 ? (
|
||||||
|
<TableRow style={{ height: virtualPaddingBottom }}>
|
||||||
|
<TableCell colSpan={allColumns.length} className="p-0" />
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map((row) => (
|
rows.map((row) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
|||||||
Reference in New Issue
Block a user