gridcn1.3.0

Lazy loading

Fetch row ranges on demand for datasets too large (or too expensive) to load up front.

data-grid-lazy gives the grid a total and a fetchRows(start, end) function, and useDataGridLazyRows fetches whatever range the grid actually scrolls into.

0 / 100,000 rows loaded
ID
Name
Email
Score
pnpm dlx shadcn@latest add @gridcn/data-grid-lazy

Lazy loading gives an infinite-feel single scrollable surface: no page boundary, a grid that fills in as the user scrolls. It fits one continuous dataset best. Explicit, bookmarkable pages with a bounded per-request payload are Pagination. Virtualization renders huge datasets cheaply either way. Neither add-on exists for rendering performance. Using both together is not supported.

loading vs. this add-on

The loading prop of the core (see Styling & theming) is a whole-grid flag with one skeleton look, for an ordinary fully-controlled fetch, with no per-row granularity and no partial data. Reach for data-grid-lazy instead when individual row ranges load independently as the user scrolls. Its skeleton cells are per-row, not grid-wide.

loading and lazy.isLoading are not combined for you. lazy.isLoading is true while any range fetch is in flight. Pass it as loading on DataGridRoot to show the slim progress bar under the header. The unloaded rows keep their skeleton cells.

Every unloaded-row skeleton cell carries data-skeleton (see Data-attribute contract), for restyling the shimmer with plain CSS instead of a class override.

Usage

"use client";

import {
  DataGridProvider,
  DataGridRoot,
  DataGridHeader,
  DataGridBody,
} from "@/components/data-grid/data-grid";
import { useDataGridLazyRows, DataGridLazyGuard } from "@/components/data-grid-lazy/data-grid-lazy";

function OrdersGrid({ total }: { total: number }) {
  const lazy = useDataGridLazyRows<Order>({
    total,
    fetchRows: (start, end, signal) => api.orders({ start, end, signal }), // inclusive-exclusive
    getRowId: (row) => row.id,
  });

  return (
    <DataGridProvider
      data={lazy.gridProps.data}
      columns={columns}
      getRowId={lazy.gridProps.getRowId}
      onDataChange={lazy.onDataChange}
      onRenderedRowsChange={lazy.gridProps.onRenderedRowsChange}
    >
      <DataGridLazyGuard hasHoles={lazy.unloadedCount > 0} />
      <DataGridRoot loading={lazy.isLoading}>
        <DataGridHeader />
        <DataGridBody />
      </DataGridRoot>
    </DataGridProvider>
  );
}

total is the length of the whole dataset and must be known before the first render. Read it from your server first, for example from a count endpoint or from a X-Total-Count response header on a first request of one row. Render the grid when the number has arrived:

const { data: total } = useQuery({ queryKey: ["orders-count"], queryFn: api.orderCount });

return total === undefined ? <Spinner /> : <OrdersGrid total={total} />;

onRenderedRowsChange is a core DataGridProvider or DataGrid prop (see Virtualization). This add-on is the first consumer of it, but the prop is generic and available even without this add-on, for example for analytics or prefetch.

Prop

Type

How it fetches

  • data is a sparse array, with length total. Loaded indices hold real rows. Unloaded indices are holes (undefined), which the core renders as skeleton cells.
  • Function-form row and cell callbacks are skipped on holes. readOnly(row), getRowClassName, and the function forms of getCellClassName / column.cellClassName never receive an undefined row. The skeleton row is inert.
  • Placeholder row ids are index-derived for unloaded rows (getRowId only runs on rows that exist). This id is unstable. It does not survive a range being unloaded and reloaded with different content at the same index, but this is fine. An unloaded row carries no selection, edit, or scroll-anchor state worth preserving across that transition.
  • Fetch ranges are expanded and rounded, not requested pixel-for-pixel: overscan pads the visible range on both sides (about 1 viewport by default), then both edges round outward to the nearest batchSize boundary, so a small scroll reuses the same in-flight or already-loaded batch.
  • Gaps wider than maxFetchRows split into chunks. Each chunk is a separate request of at most maxFetchRows rows, with its own dedup, abort, and failure. The default is no cap.
  • Already-loaded and in-flight ranges are never re-requested. A range that partially overlaps what is already loaded only fetches the remaining gap. Adjacent loaded ranges are merged, so later gap calculations see one covered span, not several.
  • Fetches are aborted on unmount, through the AbortSignal passed to fetchRows. Any fetch still in flight for an old dataset is also aborted if total changes out from under it.
  • A failed fetch reverts its range to unloaded (never marked loaded) and calls onError(error, range). The next time that range comes back into view, onRenderedRowsChange naturally re-requests it. There is no separate retry mechanism to wire up. A successful fetch calls onLoaded(range) with the range actually written; the rows commit on the next render, and getLoadedRanges() already includes the range.

Editing loaded rows

useDataGridLazyRows does not own a data and setData pair the way useDataGridState does. Pass its onDataChange straight to DataGridProvider or DataGrid, and it merges the edited array back into the sparse rows it already had loaded. What you do with the edit beyond that, whether you persist it to your backend or run it through your own cache, is up to you. Editing an unloaded row is not reachable in the first place. The skeleton-cell handling of the core blocks entering edit mode on a row that does not exist yet.

Errors

useDataGridLazyRows({
  total,
  fetchRows,
  getRowId,
  onError: (error, range) => toast.error(`Failed to load rows ${range.start}–${range.end}`),
});

onError is the only signal that a fetch failed. There is no thrown-error boundary, because a failed range stays a hole, rendered as a skeleton row, and is retried automatically the next time it is scrolled into view. The demo's "Simulate a failed fetch" and "Simulate a permanent failure" buttons show the two cases live. Permanent failures and the retry UI for them are on the next page.

onLoaded(range) is the success counterpart of onError. It fires once a range fetch resolves, with the range actually written. The rows commit on the next render. getLoadedRanges() includes the range immediately. A short response fires with the smaller range. A zero-row response fires nothing.

For data-fetch libraries (React Query) and permanently failing ranges, see Data fetching & failure recovery.

Tuning the fetch range

overscan, batchSize, and maxFetchRows control the size of the ranges that reach fetchRows:

  • overscan pads the visible range on both sides (default about one viewport of rows).
  • batchSize rounds both edges of the padded range outward to the nearest multiple, so a small scroll reuses the same batch.
  • maxFetchRows caps the rows of one fetchRows call. A wider gap splits into consecutive chunks of at most the cap. The default is no cap.

overscan and batchSize default to values that fit a typical viewport. maxFetchRows has no default cap. Adjust them when the defaults do not fit:

useDataGridLazyRows({
  total,
  fetchRows,
  getRowId,
  overscan: 60,
  batchSize: 100,
  maxFetchRows: 200,
});

Set maxFetchRows when your backend limits rows per request. Each chunk is an independent request with its own dedup, abort, and failure, and chunk boundaries follow the gap's start, not batchSize boundaries.

Memory & retention

Loaded rows stay in the sparse array until a total change, a remount, or an explicit call removes them:

  • lazy.reset() aborts every in-flight fetch and drops all loaded rows. It is the same reset a total change performs. It also re-requests the last reported range, so the visible rows refetch instead of sitting as skeletons. Use it when the dataset behind the same total is invalidated in place, for example after a server-side refresh.
  • lazy.evict(range) drops the loaded rows inside range. A partial overlap drops the intersection only, and the rest stays loaded. Evicted rows refetch on the next scroll into them.
  • lazy.getLoadedRanges() returns the loaded ranges, merged and sorted. The result never triggers a render. Call it in effects or event handlers.

An eviction policy is a consumer decision. A distance from the visible range is a starting point:

const MIN_DISTANCE = 500;

<DataGridProvider
  data={lazy.gridProps.data}
  columns={columns}
  getRowId={lazy.gridProps.getRowId}
  onRenderedRowsChange={(range) => {
    lazy.gridProps.onRenderedRowsChange(range);
    for (const loaded of lazy.getLoadedRanges()) {
      if (range.start - loaded.end > MIN_DISTANCE || loaded.start - range.end > MIN_DISTANCE) {
        lazy.evict(loaded);
      }
    }
  }}
>
  <DataGridRoot>
    <DataGridHeader />
    <DataGridBody />
  </DataGridRoot>
</DataGridProvider>

The demo's "Evict loaded rows" button runs lazy.evict over the whole range, so every loaded row becomes a skeleton and refetches on scroll.

Two behaviors matter when you wire your own policy:

  • An in-flight fetch that started before the evict still lands and marks its range loaded. The evicted rows reappear once that fetch resolves.
  • Evicted rows lose edits merged through onDataChange that you did not persist. This hook does not own persistence. See Editing loaded rows.

Undo and redo compose with this hook's onDataChange. The wiring, the hole-tolerant getRowId, and the clear()-on-reset() rule are on the Undo & redo page.

Sorting a lazy grid

The sort must run on the server. Set sortMode="server": the grid tracks the sort spec (the asc → desc → none header cycle, aria-sort, the sort indicator, DataGridSortList) and calls onSortChange, but never reorders data. Hold the spec in your own state and send it to fetchRows:

const [sorts, setSorts] = useState<SortSpec[]>([]);

const lazy = useDataGridLazyRows<Order>({
  total: 100_000,
  getRowId: (row) => row.id,
  fetchRows: (start, end, signal) => api.orders({ start, end, sorts, signal }),
});

const onSortChange = (next: SortSpec[]) => {
  // a sort change invalidates every fetched range, so drop them before the refetch runs
  lazy.reset();
  setSorts(next);
};

<DataGridProvider
  data={lazy.gridProps.data}
  getRowId={lazy.gridProps.getRowId}
  onDataChange={lazy.onDataChange}
  sortMode="server"
  onSortChange={onSortChange}
  headerClickBehavior="sort"
>

Three details make this work:

  • sortMode="server" leaves the order to the server. Passing sortState={sorts} as well is allowed: the spec shows in the headers, and the fetched rows keep the order the server sent.
  • A header click sorts only with headerClickBehavior="sort". The default, "select", selects the column and never calls onSortChange.
  • A sort change discards loaded rows. Already-fetched ranges hold the old order, so onSortChange calls lazy.reset() before the refetch runs. Remounting the provider on a key derived from the spec clears them too. It also resets the core store (selection, edit state, scroll position), so it is the heavier alternative.

Use lazy.isLoading to show a busy state while the refetch is in flight, for example to dim the grid.

Filtering follows the same rule for the same reason: filterState narrows data locally when set. searchText never narrows anything. It only highlights and navigates matches over the rows already in data, so a search over a partial array highlights matches in the loaded part and nothing more.

Client-side sort/filter/search cannot see rows it hasn't fetched

Sorting or filtering only reorders or removes rows already in data. It has no way to reason about the rest of a 100k-row dataset it never loaded. Hand your backend the sort or filter spec, and let it decide which rows to serve, rather than letting the grid sort or filter client-side over a partial array.

Mount <DataGridLazyGuard hasHoles={lazy.unloadedCount > 0} /> inside <DataGridProvider> (see the usage example above) to get a dev-only console warning if an uncontrolled sort, filter, or search gesture fires while rows are still unloaded. It cannot stop the gesture, but it flags the incompatible combination the moment it happens, instead of leaving you to notice wrong-looking results later. Clipboard and fill across unloaded ranges are blocked the same way as a non-writable column: a plain skip, not an error. Aggregates over a lazy grid are unsupported for the same reason. Use server-side totals, or compute totals outside the grid from the full dataset (see Pinned rows).

DataGridUrlState is incompatible with a lazy grid

DataGridUrlState (from URL state) writes its URL sort, filter, and search spec into the store on mount. That is the client-side view state a lazy grid keeps out of the store, so the add-on re-sorts the partial data. Do not mount it on a lazy grid. Hold the spec in your own state, as above.

Select-all includes the unloaded rows

The view is total rows long, loaded or not, so the two-stage select-all ( Ctrl/Cmd+A twice) selects the full view, holes included. getRowIds for an unloaded row in that selection returns its index-derived placeholder id (__lazy-unloaded-<index>), not a real row id. If a selection must mean "only loaded rows", filter the selected rows through the sparse data array yourself (a hole is undefined there) or read lazy.getLoadedRanges() and keep only the rows whose index falls in a loaded range. The result is a snapshot that never triggers a render. Call it in an effect or handler. An export of a lazy grid covers only the loaded rows: buildExportRows skips holes in every scope (see Import & export).

On this page