usePagination
State & dataA headless pagination state machine — current page, page count, a slice-ready range, and an ellipsis-aware pager model
Install
$ npm install @usefy/use-paginationQuick 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 reference
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 items
items — 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.
Go deeper
This page is the quick reference. For every example, prop, and edge case, read the full README — or open Storybook to change props live.