Lazy loading: data fetching & failure recovery
Wire a fetch/cache library like React Query through fetchRows, and give permanently failing ranges a retry UI.
Follow-up to Lazy loading: how to bring your own cache into fetchRows,
and what to do when a range fails permanently instead of transiently.
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 on-demand 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 view later. Two cases apply: a failed fetch's range, and a range you unload
yourself with lazy.evict(range). Use lazy.getLoadedRanges() to find the candidates for your
policy. When the evicted range comes back into view, the refetch runs through fetchRows. It
serves from cache instead of the network, as long as it is within staleTime.
When a range fails permanently
The base case (see Errors) 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 never marked loaded.
onError(error, range)fires once per failed attempt.- The next time
onRenderedRowsChangereports a range that covers the failed one, gridcn requests it again. This happens whether you scroll away and back, or the very first fetch failed and the range 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 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 onRenderedRowsChange for the same range so useDataGridLazyRows retries it —
// there's no separate imperative "retry" call, scrolling into it again is the retry.
lazy.gridProps.onRenderedRowsChange(failedRange);
setFailedRange(null);
}}
>
Retry
</button>
</div>
)}
{/* ...DataGridProvider / DataGridRoot as usual */}
</>
);
}Calling lazy.gridProps.onRenderedRowsChange(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.