gridcn1.3.0

Pagination

Explicit, bookmarkable pages with a bounded per-request payload.

data-grid-pagination is a UX and server-load pattern, not a performance need.

Name
Email
Role
Score
Silent Tiger
user0@example.com
Viewer
4
Quick Lion
user1@example.com
Viewer
89
Swift Wolf
user2@example.com
Manager
80
Eager Lion
user3@example.com
User
51
Eager Tiger
user4@example.com
Viewer
83
Silent Wolf
user5@example.com
User
15
Swift Lion
user6@example.com
Viewer
62
Eager Lion
user7@example.com
Manager
42
Eager Eagle
user8@example.com
Editor
35
Bold Tiger
user9@example.com
Editor
52
Bold Bear
user10@example.com
User
28
Swift Wolf
user11@example.com
Manager
9
Swift Bear
user12@example.com
Viewer
45
Eager Wolf
user13@example.com
Editor
44
Swift Eagle
user14@example.com
Viewer
51
Quick Lion
user15@example.com
Manager
28
Bold Wolf
user16@example.com
Editor
76
Swift Eagle
user17@example.com
Admin
26
Eager Eagle
user18@example.com
Manager
70
Eager Bear
user19@example.com
Editor
67
Silent Lion
user20@example.com
Manager
89
Silent Tiger
user21@example.com
Manager
40
Eager Tiger
user22@example.com
Manager
82
Silent Lion
user23@example.com
Admin
16
Bold Eagle
user24@example.com
Admin
38
1–25 of 240
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.

On this page