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, so this
add-on is purely additive: zero core changes.
Lazy loading vs. pagination
- Lazy loading (
data-grid-lazy) 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 (this add-on) 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.
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} />
</>
);
}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
id-keyed 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 — while the pager's total and page count still reflect the full
dataset. A search that matches rows on other pages finds nothing here. Use server mode for
dataset-wide search, sort, or filter (your backend applies the spec over the whole table).
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. Pruning the selection by row id across
page changes is a tracked backlog follow-up; until it lands, 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.
Prop
Type
Prop
Type
<DataGridPaginationBar />
The default footer has:
- First and prev buttons.
- A windowed set of page-number buttons (5 at a time, sliding to stay centered on the current page).
- Next and last buttons.
- A page-size
<Select>. - An "x-y of z" range label.
It is a standalone component. It does not read from the store of DataGridProvider, since
client-mode pagination has nothing to do with sort, filter, or selection state, so it works
whether you render it inside or outside the provider. All strings route through the
labels.pagination group (see i18n). Pass a labels prop
directly on <DataGridPaginationBar> to override them.
Composition
DataGridPaginationBar renders its default layout only when given no children. Pass
children built from the parts below for a custom arrangement, for example a compact bar with
only first, prev, next, and last, and no page numbers:
<DataGridPaginationBar {...pager.controls}>
<DataGridPaginationRange />
<div className="flex items-center gap-1">
<DataGridPaginationFirst />
<DataGridPaginationPrev />
<DataGridPaginationNext />
<DataGridPaginationLast />
</div>
</DataGridPaginationBar>Every part reads the bar's controls and labels through context, so none of them take the
pagination props directly. Only DataGridPaginationBar does. Each part throws if rendered
outside a DataGridPaginationBar.
DataGridPaginationRange— the "x-y of z" label.DataGridPaginationPageSize— the rows-per-page<Select>.DataGridPaginationFirst/DataGridPaginationLast— jump to page 1 or the last page. Disabled at their respective edge.DataGridPaginationPrev/DataGridPaginationNext— moves the page by 1, chevron-only. Disabled at their respective edge.DataGridPaginationPages— the windowed numbered page buttons, the only source of page numbers. Takes an optionalwindowSizeprop (default 5).
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 here: useDataGridPagination's client-mode pageSize seeds once from
useState and can't be pushed a new value afterward, so a URL-driven pageSize change would have
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, same as any other server-mode consumer.
page is 1-based and omitted from the URL at page 1; pageSize is omitted when it equals
useDataGridUrlPagination's configured default. Both writes use history: "replace", matching
this add-on's other URL-synced params. Invalid or out-of-range URL values (a negative or
fractional page, a pageSize outside your pageSizeOptions) clamp back to a valid value rather
than throwing.
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.
See URL state for the full option list (prefix, defaultPageSize,
pageSizeOptions).