gridcn

Virtualization

Row and column windowing: what renders, what stays fixed, and what you get for free.

Both axes are virtualized. Only the rows and columns that actually intersect the viewport, plus a small overscan buffer, ever mount as real DOM cells. This is what lets a 100,000 x 5 grid render and scroll like a 30-row one. See Performance for the headline numbers and the regression suite that enforces them.

On by default, no config

Virtualization is not a prop you opt into. It is how the grid always renders. There is nothing to configure to get it, and no escape hatch to render everything unwindowed.

Give the grid a bounded height

Without a bounded height, the viewport's clientHeight covers the whole dataset, and virtualization cannot activate. A dev build shows this warning:

gridcn: the grid viewport shows more than {N} rows at once — its height is probably unbounded,
which disables virtualization. Give the grid a bounded height (e.g. className="h-150" or h-full
inside a sized parent). Rendering is capped at {N} rows.

To fix this, give the grid, or a sized parent, an explicit height, for example className="h-150" or h-full inside a container with its own bounded height.

The model, in consumer terms

The scrollable area is a sticky viewport. The rows and columns you see are absolutely positioned inside a full-size canvas, sized to the total row and column extent. So the scrollbar's length and thumb position are always correct. A single CSS transform slides the rendered window to track scroll position. The transform moves with the scroll position on every tick, not on the next React commit. So the browser can never reveal a strip of unrendered area between a scroll event and the window catching up. This holds even during a hard scrollbar-drag fling.

Overscan, including velocity-aware overscan

A small fixed overscan, rows and columns rendered just past the visible edge, absorbs the normal gap between one scroll tick and the next window recompute. During a fast scroll or scrollbar-drag fling, that fixed buffer alone is not enough, because a single tick can jump hundreds of pixels. So the grid also tracks recent scroll velocity. It grows the overscan on the leading edge, the direction you are scrolling toward, and keeps the trailing edge at its normal minimum. This keeps a fast fling from ever exposing blank space. The window that lands after each tick already covers where the next tick is likely to land, with no permanent over-rendering while scrolling slowly or sitting still.

What's virtualized

  • Rows — only data rows that intersect the viewport, plus overscan, mount. Row height is uniform (rowHeight or density), so the window is a cheap range computation, not a per-row measurement pass.
  • Columns — only columns that intersect the horizontal viewport, plus overscan, mount, independent of row windowing. A diagonal scroll recomputes both in the same tick.

Interaction with pinned rows and columns

Pinned-left and pinned-right columns, and pinned-top and pinned-bottom row bands (see Columns and Pinned rows), always render, regardless of scroll position. They sit outside the windowed range by construction, not merely overscanned, so they never blank, flicker, or lose focus while scrolling. The viewport bounds of the unpinned window shrink to account for however much space the pinned bands and columns currently occupy, so windowing and pinning compose correctly. Scrolling never renders an unpinned cell underneath a pinned one.

What you get for free

  • There is no row or column count threshold to cross, and no "virtualize past N rows" toggle. Small and huge datasets go through the identical code path.
  • Keyboard navigation, useDataGridScrollToCell, and programmatic scroll all work with windowed content. Moving the active cell off-window scrolls it back into the rendered range automatically, so you do not need to pre-render a wider window.
  • Resize, reorder, and pin or unpin recompute the window correctly with no manual invalidation.

Keep data/columns/callback references stable

Virtualization does not change the standard identity-stability rule. data, columns, getRowId, getRowClassName and getCellClassName, and pinned-row arrays must be module-scope constants or memoized, not fresh literals on every render. See Performance. A dev-mode guardrail warns when an identity changes across renders.

Verified by a regression suite

Blank-cell detection under randomized scrollbar-drag deltas, an FPS regression gate, and a wasted-render probe all run against a real Chromium instance. See Performance and the Benchmarking section in the repository's DEVELOPMENT.md to run them yourself.

Beyond rendering: fetching data on demand

Virtualization only decides what renders. It does not fetch anything. The dataset itself can be too large or expensive to load up front, not only to render. For that case, see Lazy loading, an add-on built on the row-window callback below. It fetches windows on demand and renders skeleton rows for rows that have not arrived yet.

onRowWindowChange?: (range: { start: number; end: number }) => void fires after a row-window commit, with the rendered [start, end) data-row range, the same range that this section describes. This callback exists for exactly this kind of add-on. It is also useful for analytics or prefetch outside lazy loading.

On this page