Skip to content
usefy

usePagination

State & data

A headless pagination state machine — current page, page count, a slice-ready range, and an ellipsis-aware pager model

Install

$ npm install @usefy/use-pagination

Quick start

usePagination.tsx
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

OptionTypeDefaultDescription
totalnumber— (required)Total number of items across all pages. Values below 0 and non-finite values are treated as 0; fractions are floored.
pageSizenumber10Items per page. Clamped to a minimum of 1.
pagenumberundefinedControlled current page (1-based). When provided, the returned page mirrors this prop (clamped) and the controls only call onChange.
defaultPagenumber1Initial page in uncontrolled mode. Ignored while controlled.
siblingCountnumber1Pages shown on each side of the current page in items.
boundaryCountnumber1Pages shown at the start and end in items.
onChange(page: number) => voidundefinedCalled with the next page whenever the page changes. Its identity may change between renders freely.

Returns — UsePaginationReturn

PropertyTypeDescription
pagenumberCurrent page, 1-based, always within [1, pageCount].
pageCountnumberNumber of pages, always >= 1 (an empty dataset still has one empty page).
pageSizenumberThe effective page size (clamped to >= 1).
setPage(page: number) => voidGo to a specific page. Argument is clamped and floored; going to the current page is a no-op. Stable identity.
next() => voidGo to the next page (no-op on the last). Stable identity.
prev() => voidGo to the previous page (no-op on the first). Stable identity.
first() => voidGo to the first page. Stable identity.
last() => voidGo to the last page. Stable identity.
canNextbooleantrue when page < pageCount.
canPrevbooleantrue when page > 1.
rangePaginationRange0-based { start, end } window of the current page — see below.
itemsPaginationItem[]Ellipsis-aware pager model — see below.

rangePaginationRange

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 to total.

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

itemsPaginationItem[]

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 shrinking total never fires a surprise onChange. In controlled mode this means a page prop that is out of range is displayed clamped but not corrected via onChange; pass a valid page. 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/prev at a bound — does not re-render or call onChange.
  • SSR-safe. Pure state and math; there is no window/document access, 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.

More in state & data