Pagination
Explicit, bookmarkable pages with a bounded per-request payload.
data-grid-pagination is a UX and server-load pattern, not a performance need.
pnpm dlx shadcn@latest add @gridcn/data-grid-pagination
useDataGridPagination computes the current page (client mode slices data itself, server mode is
fully controlled), and <DataGridPaginationBar /> renders the footer, composed from parts in the
shadcn-Pagination style. Virtualization already handles rendering huge datasets cheaply either
way, so this add-on is purely additive: zero core changes. Neither it nor lazy loading exists for
rendering performance, and using the two together is not supported. When to pick which is on the
Lazy loading page.
Client mode
useDataGridPagination slices data into pages itself. Page state is uncontrolled unless you
pass page and onPageChange yourself:
"use client";
import { DataGrid, defineColumns } from "@/components/data-grid/data-grid";
import { useDataGridPagination, DataGridPaginationBar } from "@/components/data-grid-pagination/data-grid-pagination";
function OrdersGrid({ rows }: { rows: Order[] }) {
const pager = useDataGridPagination({ data: rows, pageSize: 25 });
return (
<>
<DataGrid data={pager.pageData} columns={columns} getRowId={(row) => row.id} />
<DataGridPaginationBar {...pager.controls} />
</>
);
}pageSize seeds the page size once; pass onPageSizeChange to observe a change from the bar's
select.
Edits need a merge step, not a wholesale replace
The onDataChange of DataGrid only ever sees the current page's slice. If rows are
editable, merge the edited slice back into your full dataset by row id. Do not pass the
page-sliced array straight into a setter that owns the whole dataset. That silently drops
every row outside the current page. See Recipes: server round-trip for the same
row-id-based merge pattern applied to a real API instead of local state.
In client mode, everything the grid sees is page-local
The grid only ever receives pager.pageData, so sort, filter, and search operate on the
current page's slice alone. The pager's total and page count still reflect the full
dataset. A search that matches rows on other pages finds nothing here. A pinned totals band
computed from the grid covers the page slice the same way (see Pinned rows).
Use server mode for dataset-wide search, sort, or filter (your backend applies the spec over
the whole table). Dataset-wide totals come from your server, or from the full dataset outside
the grid.
Selection does not survive a page change
The provider's store outlives a page change, so a selected cell range keeps its indices into
the next page's slice and points at different rows. The grid does not prune the
selection by row id across page changes. Key the provider by page (key={pager.page}
remounts it, clearing the selection). Or clear the selection in your onPageChange
handler before the data swap.
Streaming patches only reach the current page
The grid's store only holds the current page's rows, so a streaming updateCells batch
silently skips patches for rows that are not on the visible page (no error, no warning).
Either apply the feed to your full dataset (client mode) or to the fetched page (server mode)
and let the grid's data follow, or stream only rows the current page can see.
const onDataChange = useCallback((next: readonly DemoRow[]) => {
setRows((prev) => {
const edited = new Map(next.map((row) => [row.id, row]));
return prev.map((row) => edited.get(row.id) ?? row);
});
}, []);Server mode
Pass { page, pageSize, total, onPageChange } fully controlled. You own the page index and
fetch each page's rows yourself. The hook only computes pageCount and page-window math, so
<DataGridPaginationBar /> renders identically either way:
"use client";
import { useEffect, useState } from "react";
import { DataGrid, defineColumns } from "@/components/data-grid/data-grid";
import { useDataGridPagination, DataGridPaginationBar } from "@/components/data-grid-pagination/data-grid-pagination";
function OrdersGrid() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [rows, setRows] = useState<Order[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
// Guard against stale responses: a page-2 answer arriving after page-3's request
// must not overwrite page-3's rows (fast page clicks race the network).
let stale = false;
api.orders({ page, pageSize }).then((res) => {
if (stale) return;
setRows(res.rows);
setTotal(res.total);
});
return () => { stale = true; };
}, [page, pageSize]);
const pager = useDataGridPagination({
page,
pageSize,
total,
onPageChange: setPage,
onPageSizeChange: setPageSize,
});
return (
<>
<DataGrid data={rows} columns={columns} getRowId={(row) => row.id} />
<DataGridPaginationBar {...pager.controls} />
</>
);
}pager.pageData is undefined in server mode. You already fetched exactly the rows for the
current page, so there is nothing left for the hook to slice.
The bar clamps the displayed page when total shrinks below it, but your page state is
yours. If a stale page would fetch the wrong rows, pass reconcilePage: true. The hook then
fires your onPageChange with the clamped page once. The hook never sets your state. While
total is 0 (your first fetch is still in flight), the hook keeps your page, so a deep link
survives until the total arrives.
Prop
Type
Prop
Type
Composing with data-grid-url-state
useDataGridUrlPagination (from data-grid-url-state) syncs page and pageSize with the URL
via nuqs. It hands back the same controlled pair useDataGridPagination's server mode already
accepts, so the two compose with one spread:
"use client";
import { useDataGridUrlPagination } from "@/components/data-grid-url-state/data-grid-url-state";
import { useDataGridPagination, DataGridPaginationBar } from "@/components/data-grid-pagination/data-grid-pagination";
function OrdersGrid({ rows, total }: { rows: Order[]; total: number }) {
const url = useDataGridUrlPagination();
const pager = useDataGridPagination({ total, ...url });
return (
<>
<DataGrid data={rows} columns={columns} getRowId={(row) => row.id} />
<DataGridPaginationBar {...pager.controls} />
</>
);
}Client mode isn't a fit: its pageSize seeds once from useState and can't be pushed a new value
afterward, so a URL-driven pageSize change has nothing to land on. Server mode's
page/pageSize/onPageChange/onPageSizeChange are fully controlled, which is exactly what a
URL-backed value needs. Fetch each page's rows yourself, keyed off url.page and url.pageSize.
page (1-based) is omitted from the URL at page 1, and pageSize is omitted when it equals the
configured default. The full option list is on
URL state.
Reset to page 1 when filters or search change
useDataGridUrlPagination doesn't know about your filter/search wiring, so it can't reset the
page for you. If filtering can shrink the result set out from under the current page, call
url.onPageChange(1) yourself wherever you already handle the filter change.