Skip to content

React table virtualization — 10k rows, ~24 DOM nodes

Try it live: open a Mantine starter in StackBlitz — a real AdaptTable you can edit in the browser, no install. Other UI kits →

See it working: scroll 50,000 rows in the live demo — a real table you can scroll, not a recording.

Long lists can opt into row/card windowing with one prop: virtualize. Fifty thousand rows render as a handful of DOM nodes, on the page or inside a fixed-height box.

import { DataTable } from "@adapttable/mantine"; // or @adapttable/mui, chakra, antd, radix, shadcn, unstyled
interface Reading {
id: string;
sensor: string;
value: number;
}
const data: Reading[] = Array.from({ length: 50_000 }, (_, i) => ({
id: String(i + 1),
sensor: `Sensor ${(i % 40) + 1}`,
value: Math.round(Math.sin(i) * 1000) / 10,
}));
export function Readings() {
return (
<DataTable
data={data}
columns={[{ key: "sensor", sortable: true }, { key: "value" }]}
rowKey={(r) => r.id}
paginationMode="infinite"
virtualize
maxHeight={380}
estimateRowSize={56}
estimateCardSize={140}
/>
);
}

The estimates default to 56 px rows and 132 px cards — pass your real measured sizes (like the 140 above) when your cells differ.

  • virtualize is opt-in (default false) and applies in infinite (non-paged) mode — paged tables already cap the row count, so they never virtualize.
  • Window mode (no maxHeight): the virtual window tracks the page scroll. Use virtualScrollMargin to offset it under sticky chrome (e.g. a sticky header above the table).
  • Element mode (any maxHeight box): the same prop virtualizes inside the scroll box instead — the box is the scroller and the window tracks it.
  • Rows/cards are measured after render; estimateRowSize (desktop rows) and estimateCardSize (mobile cards) seed the math, and virtualOverscan rows are rendered beyond the visible window to keep scrolling smooth.
  • Inside a maxHeight box the page-level Load more button and infinite-scroll sentinel are suppressed: the box never grows, so the virtual window extends itself at the box’s scroll end instead.
  • Ant Design maps virtualize to antd’s native virtual table mode on desktop; on mobile the cards window through the shared engine, just like every other adapter, and the page-level sentinel keeps loading more.
PropTypeDefaultDescription
virtualizebooleanfalseWindow the rendered rows/cards on long infinite lists.
maxHeightnumberFixed-height scroll box (px); switches to element-mode windowing.
estimateRowSizenumber56Desktop row-height estimate in px.
estimateCardSizenumberMobile card-height estimate in px.
virtualOverscannumber8Extra rows/cards rendered before and after the visible window.
virtualScrollMarginnumber0Window-mode scroll offset, usually sticky chrome height.

Virtualization renders only the rows in view, so cost is bounded by the viewport — not the dataset. A measured A/B on the scale demo (Mantine adapter, the same 10,000-row dataset fully loaded, headless Chromium, 1280×900):

Rows in the DOMJS heap
Virtualized (virtualize)24169 MB
Plain table — same 10,000 rows10,000347 MB

Windowing mounts 417× fewer DOM nodes (24 vs 10,000 — a viewport’s worth plus overscan) and uses ~178 MB less memory, roughly half — while the plain table blocks the main thread rendering ten thousand <tr>s.

And it stays flat: the rendered row count holds at ~24 whether the dataset is 1,000 or 100,000 rows. Only your own data array grows — never the table’s DOM:

Rows in the dataset1,00010,00050,000100,000
Rows in the DOM24242424

Reproduce both with scripts/bench-virtualization.mjs against a running showcase. (Numbers are from one dev laptop; the shape — constant DOM, about half the memory — does not change with hardware.)

  • Virtualization is optional — leave it off for small lists or paged tables.
  • Combining virtualize with renderRowDetail is not recommended: desktop detail panels render as unmeasured sibling rows, so scroll heights can drift (a dev-mode warning says so). Prefer paged data with row details.
  • The headless hook is exported as useTableVirtualization for custom markup; when disabled it returns every row with no spacers, so one render path serves both cases.

See it live in the demo.