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.
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
datais a sparse array, with lengthtotal. 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 ofgetCellClassName/column.cellClassNamenever receive anundefinedrow. The skeleton row is inert. - Placeholder row ids are index-derived for unloaded rows (
getRowIdonly 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:
overscanpads the visible range on both sides (about 1 viewport by default), then both edges round outward to the nearestbatchSizeboundary, so a small scroll reuses the same in-flight or already-loaded batch. - Gaps wider than
maxFetchRowssplit into chunks. Each chunk is a separate request of at mostmaxFetchRowsrows, 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
AbortSignalpassed tofetchRows. Any fetch still in flight for an old dataset is also aborted iftotalchanges 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,onRenderedRowsChangenaturally re-requests it. There is no separate retry mechanism to wire up. A successful fetch callsonLoaded(range)with the range actually written; the rows commit on the next render, andgetLoadedRanges()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:
overscanpads the visible range on both sides (default about one viewport of rows).batchSizerounds both edges of the padded range outward to the nearest multiple, so a small scroll reuses the same batch.maxFetchRowscaps the rows of onefetchRowscall. 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 atotalchange 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 sametotalis invalidated in place, for example after a server-side refresh.lazy.evict(range)drops the loaded rows insiderange. 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
onDataChangethat 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. PassingsortState={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 callsonSortChange. - A sort change discards loaded rows. Already-fetched ranges hold the old order, so
onSortChangecallslazy.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).