@usefy/use-pagination
v0.25.1
Published
A React hook for pagination state — controlled/uncontrolled current page, page count, range, and an ellipsis-aware page-number model
Maintainers
Readme
Overview
usePagination is part of the @usefy ecosystem — a collection of production-ready, TypeScript-first, SSR-safe React hooks. It owns pagination state — nothing else — so you can render the pager however you like: a table footer, a numbered button row, a "load more" counter, an API cursor.
From total and pageSize it derives the page count, keeps the current page clamped in range, hands you a slice-ready index window (range) for your data array, and builds the ellipsis-aware page-number model (items) you need for a 1 … 24 25 26 … 50 pager — the same model as MUI/Mantine's usePagination. The current page works in both controlled and uncontrolled modes, composing @usefy/use-controllable-state.
Features
- Controlled & uncontrolled — pass
page+onChange, or let the hook own the page fromdefaultPage - Slice-ready
range— a 0-based{ start, end }window (end exclusive, clamped tototal) sodata.slice(start, end)just works - Ellipsis-aware
items— page numbers +"ellipsis"tokens driven bysiblingCount/boundaryCount, each flaggedselectedfor the current page - Always in range — the page is clamped into
[1, pageCount]; when the dataset shrinks the current page follows it, no dangling out-of-range page - No wasted renders —
next/prevat a bound andsetPageto the current page are no-ops (no re-render, noonChange) - Stable controls —
setPage/next/prev/first/lastkeep their identity across renders, safe as effect deps - Robust guards — non-positive
pageSizeis coerced to1, negative/NaNtotalto0, fractional pages floored - TypeScript-first — full type inference and exported types
- Tiny & tree-shakeable — one tiny dependency, published as its own package
Installation
# npm
npm install @usefy/use-pagination
# yarn
yarn add @usefy/use-pagination
# pnpm
pnpm add @usefy/use-paginationRequires React 18 or 19 (peerDependencies: "react": "^18.0.0 || ^19.0.0").
Quick Start
import { usePagination } from "@usefy/use-pagination";
function UsersTable({ users }: { users: User[] }) {
const { page, pageCount, range, items, setPage, next, prev, canNext, canPrev } =
usePagination({ total: users.length, pageSize: 10 });
const visible = users.slice(range.start, range.end);
return (
<>
<ul>
{visible.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
<nav>
<button onClick={prev} disabled={!canPrev}>‹ Prev</button>
{items.map((item, i) =>
item.type === "ellipsis" ? (
<span key={`gap-${i}`}>…</span>
) : (
<button
key={item.page}
aria-current={item.selected ? "page" : undefined}
onClick={() => setPage(item.page!)}
>
{item.page}
</button>
)
)}
<button onClick={next} disabled={!canNext}>Next ›</button>
</nav>
<p>Page {page} of {pageCount}</p>
</>
);
}API
const pagination = usePagination(options);Options — UsePaginationOptions
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| total | number | — (required) | Total number of items across all pages. Values below 0 and non-finite values are treated as 0; fractions are floored. |
| pageSize | number | 10 | Items per page. Clamped to a minimum of 1. |
| page | number | undefined | Controlled current page (1-based). When provided, the returned page mirrors this prop (clamped) and the controls only call onChange. |
| defaultPage | number | 1 | Initial page in uncontrolled mode. Ignored while controlled. |
| siblingCount | number | 1 | Pages shown on each side of the current page in items. |
| boundaryCount | number | 1 | Pages shown at the start and end in items. |
| onChange | (page: number) => void | undefined | Called with the next page whenever the page changes. Its identity may change between renders freely. |
Returns — UsePaginationReturn
| Property | Type | Description |
| --- | --- | --- |
| page | number | Current page, 1-based, always within [1, pageCount]. |
| pageCount | number | Number of pages, always >= 1 (an empty dataset still has one empty page). |
| pageSize | number | The effective page size (clamped to >= 1). |
| setPage | (page: number) => void | Go to a specific page. Argument is clamped and floored; going to the current page is a no-op. Stable identity. |
| next | () => void | Go to the next page (no-op on the last). Stable identity. |
| prev | () => void | Go to the previous page (no-op on the first). Stable identity. |
| first | () => void | Go to the first page. Stable identity. |
| last | () => void | Go to the last page. Stable identity. |
| canNext | boolean | true when page < pageCount. |
| canPrev | boolean | true when page > 1. |
| range | PaginationRange | 0-based { start, end } window of the current page — see below. |
| items | PaginationItem[] | Ellipsis-aware pager model — see below. |
range — PaginationRange
The 0-based index window for slicing your data array:
start— index of the first item on the current page (inclusive).end— index one past the last item (exclusive), clamped tototal.
So array.slice(range.start, range.end) yields exactly the current page's items. For an empty dataset, start === end === 0.
// total: 25, pageSize: 10, page: 3 → { start: 20, end: 25 }
data.slice(20, 25); // the last 5 itemsitems — PaginationItem[]
The ordered model for rendering a pager, MUI/Mantine-style. Each entry is either a page button or an "ellipsis" gap:
interface PaginationItem {
type: "page" | "ellipsis";
page: number | null; // 1-based page for "page", null for "ellipsis"
selected: boolean; // true only for the current page
}siblingCount (pages around the current page) and boundaryCount (pages pinned at each end) control how the middle collapses. When a gap would hide exactly one page, that page is shown instead of an ellipsis:
// pageCount 50, page 25, siblingCount 1, boundaryCount 1:
// [1, "ellipsis", 24, 25, 26, "ellipsis", 50]The pure builder is also exported for non-React use:
import { buildPaginationRange, getPageCount } from "@usefy/use-pagination";
getPageCount(95, 10); // 10
buildPaginationRange({ page: 1, pageCount: 10 }); // [1, 2, 3, 4, 5, "ellipsis", 10]Behavior notes
- Clamping is derived. The current page is clamped presentationally into
[1, pageCount]— it is never written back into state, so shrinkingtotalnever fires a surpriseonChange. In controlled mode this means apageprop that is out of range is displayed clamped but not corrected viaonChange; pass a validpage. Any navigation interaction (next/prev/setPage) commits a fresh in-range value. - No-op skipping. Moving to the page you're already on — including
next/prevat a bound — does not re-render or callonChange. - SSR-safe. Pure state and math; there is no
window/documentaccess, so it renders identically on the server and client.
Testing
📊 View Detailed Coverage Report (GitHub Pages) — 48 tests, 100% statement coverage.
License
MIT © mirunamu
This package is part of the usefy monorepo.
