Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | 1x 1x 1x 50x 50x 50x 50x 50x 50x 50x 50x 50x 50x 50x 20x 20x 20x 5x 15x 50x 7x 50x 4x 50x 1x 50x 2x 50x 50x 50x 49x 49x 49x 50x 49x 230x 50x 49x | import { useCallback, useMemo, useRef } from "react";
import { useControllableState } from "@usefy/use-controllable-state";
import type {
PaginationItem,
PaginationRange,
UsePaginationOptions,
UsePaginationReturn,
} from "./types";
import { buildPaginationRange, clampPage, getPageCount, toSafeInt } from "./utils";
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SIBLING_COUNT = 1;
const DEFAULT_BOUNDARY_COUNT = 1;
/**
* A headless pagination state machine — the current page (controlled or
* uncontrolled), the derived page count, a slice-ready index range, and an
* ellipsis-aware page-number model for rendering a pager UI. It owns *state*
* only; you render however you like.
*
* The current page is always kept within `[1, pageCount]`: it is clamped
* **derived** from `total`/`pageSize`, so when the dataset shrinks the returned
* `page` never points past the last page and the navigation controls stay
* correct (a plain re-clamp, no surprise `onChange`). All controls
* (`setPage`/`next`/`prev`/`first`/`last`) have stable identities and skip
* no-op moves (staying on the current page does not re-render or fire
* `onChange`).
*
* Works in both modes, mirroring `useControllableState`:
* - **Uncontrolled** (`page` omitted): the hook owns the page, seeded from
* `defaultPage`; `onChange` fires after each committed change.
* - **Controlled** (`page` provided): the returned `page` mirrors the prop
* (clamped) and the controls only call `onChange` — the parent owns the value.
*
* @param options - See {@link UsePaginationOptions}.
* @returns The pagination state and controls — see {@link UsePaginationReturn}.
*
* @example
* ```tsx
* 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}
* onClick={() => setPage(item.page!)}
* >
* {item.page}
* </button>
* )
* )}
* <button onClick={next} disabled={!canNext}>Next</button>
* </nav>
* <p>Page {page} of {pageCount}</p>
* </>
* );
* }
* ```
*/
export function usePagination(
options: UsePaginationOptions
): UsePaginationReturn {
const {
total: rawTotal,
pageSize: rawPageSize = DEFAULT_PAGE_SIZE,
page: controlledPage,
defaultPage = 1,
siblingCount = DEFAULT_SIBLING_COUNT,
boundaryCount = DEFAULT_BOUNDARY_COUNT,
onChange,
} = options;
const total = toSafeInt(rawTotal, 0, 0);
const pageSize = toSafeInt(rawPageSize, 1, 1);
const pageCount = getPageCount(rawTotal, rawPageSize);
// Raw (unclamped) page state, controllable. We always write *concrete*
// clamped values through this setter (never updater functions), so the fact
// that its internal value may drift out of range while the dataset shrinks
// never leaks — the `page` below is always the clamped, derived value.
const [rawPage, setRawPage] = useControllableState<number>({
value: controlledPage,
defaultValue: defaultPage,
onChange,
});
const page = clampPage(rawPage, pageCount);
// Mirror the latest derived values + setter into refs so the controls can be
// fully identity-stable (empty dep arrays) while still reading fresh state.
const stateRef = useRef({ page, pageCount });
stateRef.current = { page, pageCount };
const setRawPageRef = useRef(setRawPage);
setRawPageRef.current = setRawPage;
const setPage = useCallback((next: number) => {
const { page: current, pageCount: count } = stateRef.current;
const target = clampPage(next, count);
if (target === current) {
return; // no-op: staying put must not re-render or fire onChange
}
setRawPageRef.current(target);
}, []);
const next = useCallback(() => {
setPage(stateRef.current.page + 1);
}, [setPage]);
const prev = useCallback(() => {
setPage(stateRef.current.page - 1);
}, [setPage]);
const first = useCallback(() => {
setPage(1);
}, [setPage]);
const last = useCallback(() => {
setPage(stateRef.current.pageCount);
}, [setPage]);
const canNext = page < pageCount;
const canPrev = page > 1;
const range = useMemo<PaginationRange>(() => {
const start = (page - 1) * pageSize;
const end = Math.min(start + pageSize, total);
return { start, end };
}, [page, pageSize, total]);
const items = useMemo<readonly PaginationItem[]>(
() =>
buildPaginationRange({
page,
pageCount,
siblingCount,
boundaryCount,
}).map((entry) =>
entry === "ellipsis"
? { type: "ellipsis", page: null, selected: false }
: { type: "page", page: entry, selected: entry === page }
),
[page, pageCount, siblingCount, boundaryCount]
);
return useMemo<UsePaginationReturn>(
() => ({
page,
pageCount,
pageSize,
setPage,
next,
prev,
first,
last,
canNext,
canPrev,
range,
items,
}),
[
page,
pageCount,
pageSize,
setPage,
next,
prev,
first,
last,
canNext,
canPrev,
range,
items,
]
);
}
|