Lazy loading
Fetch row windows 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 window the grid actually scrolls into.
pnpm dlx shadcn@latest add @gridcn/data-grid-lazy
Lazy loading vs. pagination
Both exist because they solve different problems:
- Lazy loading (this add-on) gives an infinite-feel single scrollable surface. The user never sees a page boundary, only a grid that fills in as they scroll. This works best when the grid needs to feel like one continuous dataset.
- Pagination (
data-grid-pagination) gives explicit, bookmarkable pages with a bounded per-request payload. This works best when users need to reference "page 3" specifically, or your backend is happier with fixed-size requests.
Virtualization already handles rendering 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
windows load independently as the user scrolls. Its skeleton cells are per-row, not
grid-wide, and the two are not meant to be combined.
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() {
const lazy = useDataGridLazyRows<Order>({
total: 100_000,
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}
>
<DataGridLazyGuard hasHoles={lazy.unloadedCount > 0} />
<DataGridRoot onRowWindowChange={lazy.gridProps.onRowWindowChange}>
<DataGridHeader />
<DataGridBody />
</DataGridRoot>
</DataGridProvider>
);
}onRowWindowChange is a core DataGridRoot 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.- 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, instead of firing a new request on every tick. - Already-loaded and in-flight ranges are never re-requested. A window 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,onRowWindowChangenaturally re-requests it. There is no separate retry mechanism to wire up.
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.
Sorting a lazy grid
The sort must run on the server. Hold the spec in your own state, keep the grid's sortState
permanently empty, and send the spec 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 }),
});
// key: a sort change invalidates every fetched window, which a remount discards
<DataGridProvider
key={sorts.map((s) => `${s.columnId}:${s.direction}`).join(",")}
data={lazy.gridProps.data}
getRowId={lazy.gridProps.getRowId}
onDataChange={lazy.onDataChange}
sortState={EMPTY_SORT}
onSortChange={setSorts}
>Two details make this work:
sortStatestays[]. Passing it at all is what puts sorting in controlled mode, so a header click reports throughonSortChangeinstead of sorting locally. Echoing the spec back into the prop would make the grid sortdataby it, re-ordering the fetched window among itself instead of showing the server's order.- A sort change discards loaded rows. Already-fetched windows hold the old order, and
useDataGridLazyRowsonly drops loaded ranges whentotalchanges. Remounting on a key derived from the spec is the way to clear them.
Because the store's sortState is empty, the header's sort indicator and aria-sort stay blank
and DataGridSortList shows no active sorts. Render the active sort from your own state instead.
toggleSort also derives its next direction from the store, so re-derive the
asc → desc → none cycle against your own spec, as data-grid-playground-demo does.
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.
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 track the loaded ranges in your own state.
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 simply stays a hole, rendered as a skeleton row, and is retried automatically the
next time it is scrolled into view.
Wiring a fetch/cache library (React Query)
fetchRows is a plain (start, end, signal) => Promise<TData[]>. Nothing about
useDataGridLazyRows knows or cares whether that promise came from fetch, a cache, or a data
library. Range coalescing already keeps requests infrequent (overscan and batchSize rounding,
in-flight dedup), so most apps do not need one. If you already use
React Query elsewhere and want its cache, retry, and dedup behavior
for these ranges too, wire it through fetchRows with a per-range query key:
Consumer wiring, not an add-on dependency
This is a snippet for your own app, not part of data-grid-lazy. The add-on has no React Query
dependency, and installing it through shadcn add never pulls React Query in. Add
@tanstack/react-query to your own app first if you want this pattern. For a NON-lazy grid
(a plain fetched array, no windowed loading), see
Recipes: TanStack Query wiring for the useQuery +
useMutation shape instead.
"use client";
import { useMemo } from "react";
import { QueryClient } from "@tanstack/react-query";
import { useDataGridLazyRows } from "@/components/data-grid-lazy/data-grid-lazy";
const queryClient = new QueryClient();
/** One query key per fetched range — same-range requests dedupe/cache instead of re-fetching. */
function orderRangeKey(start: number, end: number) {
return ["orders", "range", start, end] as const;
}
function OrdersGrid() {
const lazy = useDataGridLazyRows<Order>({
total: 100_000,
getRowId: (row) => row.id,
fetchRows: (start, end, signal) =>
queryClient.fetchQuery({
queryKey: orderRangeKey(start, end),
queryFn: () => api.orders({ start, end, signal }),
staleTime: 60_000,
}),
});
// ...
}useDataGridLazyRows already merges adjacent loaded ranges and never re-requests a covered
range itself. So the cache of React Query mostly pays off for ranges that get unloaded and
scrolled back into later. Examples are a failed fetch's range, or a range evicted by your own
eviction policy. Those ranges come back from cache instead of hitting the network again, as
long as they are within staleTime.
When a range fails permanently
The base case (see Errors above) is a transient failure. onError fires, the range
stays a hole, and scrolling back over it retries automatically. But the underlying cause can be
permanent instead, for example an expired auth token or a route the backend removed. If so, that
automatic retry only fails again in the same way every time the range comes back into view.
useDataGridLazyRows does not distinguish transient failures from permanent ones. It has no way
to, because fetchRows is opaque to it. What you get for a permanent failure is exactly what you
get for a transient one:
- The range's rows revert to holes and render as skeleton cells again. They never left the "unloaded" state, because a failed range is simply never marked loaded.
onError(error, range)fires once per failed attempt.- The next time
onRowWindowChangereports a window that covers that range, gridcn requests it again. This happens whether you scroll away and back, or the very first fetch failed and the window covers the initial mount range. If the cause is still there, the request fails again.
The consumer-side pattern for this is a retry UI driven by onError, so a permanently-failing
range does not just retry silently forever every time it is scrolled past. Surface it, and let
the user force a retry once whatever was wrong is fixed:
"use client";
import { useState } from "react";
import { useDataGridLazyRows, type Range } from "@/components/data-grid-lazy/data-grid-lazy";
function OrdersGrid() {
const [failedRange, setFailedRange] = useState<Range | null>(null);
const lazy = useDataGridLazyRows<Order>({
total: 100_000,
getRowId: (row) => row.id,
fetchRows: (start, end, signal) => api.orders({ start, end, signal }),
onError: (error, range) => setFailedRange(range),
});
return (
<>
{failedRange && (
<div role="alert" className="flex items-center justify-between gap-2 border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-sm">
<span>Couldn't load rows {failedRange.start}–{failedRange.end}.</span>
<button
type="button"
onClick={() => {
// re-fires onRowWindowChange for the same range so useDataGridLazyRows retries it —
// there's no separate imperative "retry" call, scrolling into it again is the retry.
lazy.gridProps.onRowWindowChange(failedRange);
setFailedRange(null);
}}
>
Retry
</button>
</div>
)}
{/* ...DataGridProvider / DataGridRoot as usual */}
</>
);
}Calling lazy.gridProps.onRowWindowChange(failedRange) directly, rather than waiting for a real
scroll, is the whole retry. The hook has no separate retry method, because re-requesting an
uncovered range is already its normal, automatic behavior. A manual retry button only triggers
that same path on demand, instead of waiting for the user to scroll there again.
The data-grid-lazy-demo above has both buttons live. "Simulate a failed fetch" is the transient
case: it fails once, then the very next attempt, whether automatic on scroll or through the
banner's Retry button, succeeds. "Simulate a permanent failure" fails every attempt until you
click "Fix backend". So mashing Retry keeps failing in exactly the same way until the underlying
cause is actually fixed. A real expired-token or removed-route scenario behaves the same way.