Data · Virtualised table
Virtualised table
Opt-in virtualisation for the case where the DOM, not the data, is the bottleneck. The visible rows are still real table rows — two hidden spacer rows preserve the layout — so semantics never degrade into absolutely-positioned divs.
Examples
import { DataTablePagination, DataTableToolbar, StatCard, VirtualDataTable } from "@luc/ui";10,000 fills
Scroll, sort and filter the whole set. Only the window plus a small overscan is mounted, so interaction stays responsive without pretending this is a benchmark.
| Side |
|---|
1–10000 of 10000 fills
<VirtualDataTable<LedgerRow>
ariaLabel="Trade ledger"
columns={columns}
data={ROWS}
getRowId={(row) => row.id}
height={520}
pageSize={10_000}
stickyHeader
onRenderedRowCountChange={setRendered}
toolbar={<DataTableToolbar search={{ label: "Search fills" }} />}
footer={<DataTablePagination rowLabel="fills" pageSizeOptions={[25, 50, 100, 250]} />}
/>When to virtualise
Virtualising twenty rows costs correctness and buys nothing.
Use DataTable
- Under a few hundred rows on one page.
- When browser find-in-page and text selection matter most.
- Anywhere row expansion or variable row heights are used.
Use VirtualDataTable
- Thousands of rows in one scroll area.
- Fixed row height and no expansion.
- When mounting every row measurably slows interaction.
<VirtualDataTable
columns={columns}
data={rows} // 10,000 rows
getRowId={(row) => row.id}
height={520}
rowHeight={40} // must match the rendered row
overscan={12}
stickyHeader
ariaLabel="Trade ledger"
/>API
Read from the published TypeScript API, so a prop that is not listed here does not exist and a default that is listed is the one the compiler sees.
| Export | Kind | Signature |
|---|---|---|
VirtualDataTable | 52 documented props | <TData extends RowData>({ ariaLabel, ariaDescription, caption, state: tableState, emptyState, errorState, density, rowHeight, overscan, height, stickyHeader, showColumnBorders, toolbar, footer, onRow… |
VirtualDataTable props
Type: VirtualDataTableProps<TData>
Values the compiler reads from the declaration.
| Prop | Type | Default | Notes |
|---|---|---|---|
ariaDescription? | string | undefined | — | |
ariaLabel | string | — | |
caption? | ReactNode | — | |
className? | string | undefined | — | |
columnFilters? | ColumnFiltersState | undefined | — | |
columnPinning? | ColumnPinningState | undefined | — | |
columns | readonly DataTableColumn<TData>[] | — | |
columnVisibility? | ColumnVisibilityState | undefined | — | |
data | TData[] | — | |
defaultColumnFilters? | ColumnFiltersState | undefined | — | |
defaultColumnPinning? | ColumnPinningState | undefined | — | |
defaultColumnVisibility? | ColumnVisibilityState | undefined | — | |
defaultGlobalFilter? | string | undefined | — | |
defaultPagination? | PaginationState | undefined | — | |
defaultRowSelection? | RowSelectionState | undefined | — | |
defaultSorting? | SortingState | undefined | — | |
density? | DataTableDensity | undefined | — | |
emptyState? | ReactNode | — | |
enableColumnVisibility? | boolean | undefined | — | |
enableFiltering? | boolean | undefined | — | |
enableGlobalFilter? | boolean | undefined | — | |
enableRowSelection? | boolean | "single" | undefined | — | Adds a leading checkbox column. `"single"` restricts to one row. |
enableSorting? | boolean | undefined | — | |
errorState? | ReactNode | — | |
footer? | ReactNode | — | |
getRowId? | ((row: TData, index: number) => string) | undefined | — | Stable row identity. Required for selection to survive sorting/filtering. |
globalFilter? | string | undefined | — | |
height? | string | number | undefined | — | Viewport height of the scroll area. Default `32rem`. |
manualFiltering? | boolean | undefined | — | |
manualPagination? | boolean | undefined | — | Server-side modes: the table stops slicing and expects prepared data. |
manualSorting? | boolean | undefined | — | |
onColumnFiltersChange? | ((filters: ColumnFiltersState) => void) | undefined | — | |
onColumnPinningChange? | ((pinning: ColumnPinningState) => void) | undefined | — | |
onColumnVisibilityChange? | ((visibility: ColumnVisibilityState) => void) | undefined | — | |
onGlobalFilterChange? | ((value: string) => void) | undefined | — | |
onPaginationChange? | ((pagination: PaginationState) => void) | undefined | — | |
onRenderedRowCountChange? | ((count: number) => void) | undefined | — | Reports how many rows are actually in the DOM, for honest status text. |
onRowClick? | ((row: TData) => void) | undefined | — | |
onRowSelectionChange? | ((selection: RowSelectionState) => void) | undefined | — | |
onSortingChange? | ((sorting: SortingState) => void) | undefined | — | |
overscan? | number | undefined | — | Rows rendered beyond the viewport. 12 is smooth without wasting frames. |
pageCount? | number | undefined | — | Total pages / rows known to the server. |
pageSize? | number | undefined | — | Default page size when the caller does not control pagination. |
pagination? | PaginationState | undefined | — | |
rowCount? | number | undefined | — | |
rowHeight? | number | undefined | — | Row height in pixels. Must match the rendered row for correct scrolling. |
rowSelection? | RowSelectionState | undefined | — | |
showColumnBorders? | boolean | undefined | — | |
sorting? | SortingState | undefined | — | |
state? | DataTableState | undefined | — | |
stickyHeader? | boolean | undefined | — | Sticky headers are the point of a virtual table; on by default. |
toolbar? | ReactNode | — |
Usage
import { VirtualDataTable } from "@luc/ui/data-table";<VirtualDataTable
columns={columns}
data={rows}
ariaLabel="All 12,480 trades"
height={520}
/>Guidelines
Do
- Reach for it above a few hundred rows, or when the table sits inside a fixed-height panel and the page must not grow.
- Keep `ariaLabel` descriptive of the whole dataset, because only the visible rows are in the DOM.
Don't
- Virtualise a table whose rows must all be reachable by browser find-in-page or by a print stylesheet; the DOM only holds the window.
Accessibility
The header stays a real header row while the body is windowed, and the caption names the full dataset so the row count does not read as the DOM count.
Related