├── .editorconfig
├── .github
└── workflows
│ └── release.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── media
└── react-use-pagination.png
├── package.json
├── src
├── Pagination.tsx
├── __tests__
│ ├── getPaginationMeta.test.ts
│ ├── paginationStateReducer.test.ts
│ └── usePagination.test.ts
├── getPaginationMeta.ts
├── index.ts
├── paginationStateReducer.ts
└── usePagination.ts
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = true
6 | indent_size = 4
7 | indent_style = space
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 | on:
3 | push:
4 | branches:
5 | - master
6 | jobs:
7 | test:
8 | name: Test & Build on Node ${{ matrix.node }} and ${{ matrix.os }}
9 | runs-on: ${{ matrix.os }}
10 | strategy:
11 | matrix:
12 | node: [16]
13 | os:
14 | - ubuntu-latest
15 | - windows-latest
16 | - macOS-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v2
20 | - name: Setup Node
21 | uses: actions/setup-node@v2
22 | with:
23 | node-version: ${{ matrix.node }}
24 | - name: Install npm dependencies
25 | run: npm i
26 | env:
27 | CI: true
28 | - name: Run tests
29 | run: npm run test --if-present
30 | - name: Verify build succeeds
31 | run: npm run prepack --if-present
32 | lint:
33 | name: Lint & Typecheck
34 | runs-on: ubuntu-latest
35 | steps:
36 | - name: Checkout
37 | uses: actions/checkout@v2
38 | - name: Setup Node
39 | uses: actions/setup-node@v2
40 | with:
41 | node-version: 16
42 | - name: Install npm dependencies
43 | run: npm i
44 | env:
45 | CI: true
46 | - name: lint
47 | run: npm run lint --if-present
48 | - name: typecheck
49 | run: npm run typecheck --if-present
50 | release:
51 | name: Release
52 | needs:
53 | - test
54 | - lint
55 | runs-on: ubuntu-latest
56 | steps:
57 | - name: Checkout
58 | uses: actions/checkout@v2
59 | with:
60 | fetch-depth: 0
61 | - name: Setup Node.js
62 | uses: actions/setup-node@v2
63 | with:
64 | node-version: 16
65 | - name: Install dependencies
66 | run: npm i
67 | env:
68 | CI: true
69 | - name: Release
70 | env:
71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
73 | run: npx semantic-release
74 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | .vscode
4 | coverage
5 | dist-*
6 | package-lock.json
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [2.0.1](https://github.com/erictooth/react-use-pagination/compare/v2.0.0...v2.0.1) (2021-06-23)
2 |
3 |
4 | ### Bug Fixes
5 |
6 | * update reducer when usePagination re-renders with different totalItems ([4cbfa21](https://github.com/erictooth/react-use-pagination/commit/4cbfa213e2172855166e69890ae1de184673a6e9))
7 |
8 | # [2.0.0](https://github.com/erictooth/react-use-pagination/compare/v1.1.1...v2.0.0) (2021-06-23)
9 |
10 |
11 | * refactor!: change Pagination from default to named export ([cfdd6ec](https://github.com/erictooth/react-use-pagination/commit/cfdd6ece204e15ca8000cc1bec5fc00b9b87ba1a))
12 |
13 |
14 | ### BREAKING CHANGES
15 |
16 | * flowtypes are no exported
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 erictooth
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | A React hook to help manage pagination state
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ## ✨ Features
24 |
25 | - 🛠 State-only hook & callbacks, you provide your own UI controls
26 | - 📦 Compatible with any pagination method like [GraphQL Relay Cursor](https://facebook.github.io/relay/graphql/connections.htm), OData, etc.
27 | - ⚡️ Works with both server side and client side pagination
28 | - 🐜 Simple and lightweight — less than 2KB gzipped
29 |
30 | ## Example
31 |
32 | ```jsx
33 | import { usePagination } from "react-use-pagination";
34 |
35 | function App() {
36 | const [data] = React.useState([]); // <- your data
37 |
38 | const {
39 | currentPage,
40 | totalPages,
41 | setNextPage,
42 | setPreviousPage,
43 | nextEnabled,
44 | previousEnabled,
45 | startIndex,
46 | endIndex,
47 | } = usePagination({ totalItems: data.length });
48 |
49 | return (
50 |
51 |
52 |
53 |
56 |
57 | Current Page: {currentPage} of {totalPages}
58 |
59 |
62 |
63 | );
64 | }
65 | ```
66 |
67 | ### Detailed Example
68 | [Try out an example on CodeSandbox](https://codesandbox.io/s/react-use-pagination-example-30jy6) that showcases full server side pagination
69 |
70 | ## API
71 |
72 | `const paginationState = usePagination(options);`
73 |
74 | ### `options`
75 |
76 | ```ts
77 | type Options = {
78 | totalItems: number;
79 | initialPage?: number; // (default: 0)
80 | initialPageSize?: number; // (default: 0)
81 | };
82 | ```
83 |
84 | ### `paginationState`
85 |
86 | ```ts
87 | type PaginationState = {
88 | // The current page
89 | currentPage: number;
90 |
91 | // The first index of the page window
92 | startIndex: number;
93 |
94 | // The last index of the page window
95 | endIndex: number;
96 |
97 | // Whether the next button should be enabled
98 | nextEnabled: number;
99 |
100 | // Whether the previous button should be enabled
101 | previousEnabled: number;
102 |
103 | // The total page size
104 | pageSize: number;
105 |
106 | // Jump directly to a page
107 | setPage: (page: number) => void;
108 |
109 | // Jump to the next page
110 | setNextPage: () => void;
111 |
112 | // Jump to the previous page
113 | setPreviousPage: () => void;
114 |
115 | // Set the page size
116 | setPageSize: (pageSize: number, nextPage?: number = 0) => void;
117 | };
118 | ```
119 |
120 | ### Client Side Pagination
121 |
122 | `startIndex` and `endIndex` can be used to implement client-side pagination. The simplest possible usage is to pass these properties directly to `Array.slice`:
123 |
124 | ```jsx
125 | const [data] = React.useState(["apple", "banana", "cherry"]);
126 | const { startIndex, endIndex } = usePagination({ totalItems: data.length, initialPageSize: 1 });
127 |
128 | return (
129 |
130 | {data.slice(startIndex, endIndex + 1).map((item) => (
131 | - {item}
132 | ))}
133 |
134 | );
135 | ```
136 |
137 | ### Server Side Pagination
138 | You can find a complete working example in the [Detailed Example](https://github.com/erictooth/react-use-pagination#detailed-example) section
139 |
140 | `startIndex` and `pageSize` can be used to implement a standard limit/offset (also known as top/skip) type of pagination:
141 |
142 | ```jsx
143 | // Keep track of length separately from data, since data fetcher depends on pagination state
144 | const [length, setLength] = React.useState(0);
145 |
146 | // Pagination hook
147 | const { startIndex, pageSize } = usePagination({ totalItems: length, initialPageSize: 1 });
148 |
149 | // Fetch Data
150 | const [_, data] = usePromise(
151 | React.useCallback(
152 | () => fetchUsers({ offset: startIndex, limit: pageSize }),
153 | [startIndex, pageSize]
154 | )
155 | );
156 |
157 | // When data changes, update length
158 | React.useEffect(() => {
159 | setLength(data.length);
160 | }, [data]);
161 |
162 | return (
163 |
164 | {data.map((item) => (
165 | - {item}
166 | ))}
167 |
168 | );
169 | ```
170 |
--------------------------------------------------------------------------------
/media/react-use-pagination.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/erictooth/react-use-pagination/1a0457e39dd5d311796b2551e370f4f095d35ed0/media/react-use-pagination.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-use-pagination",
3 | "version": "2.0.1",
4 | "description": "A React hook to help manage pagination state",
5 | "keywords": [
6 | "pagination",
7 | "paging",
8 | "pager",
9 | "table"
10 | ],
11 | "author": "erictooth",
12 | "license": "MIT",
13 | "repository": {
14 | "type": "git",
15 | "url": "https://github.com/erictooth/react-use-pagination.git"
16 | },
17 | "homepage": "https://github.com/erictooth/react-use-pagination/blob/master/README.md",
18 | "bugs": {
19 | "url": "https://github.com/erictooth/react-use-pagination/issues"
20 | },
21 | "sideEffects": false,
22 | "main": "dist-node/react-use-pagination.js",
23 | "module": "dist-module/react-use-pagination.mjs",
24 | "types": "dist-types/index.d.ts",
25 | "files": [
26 | "dist-module",
27 | "dist-node",
28 | "dist-types"
29 | ],
30 | "scripts": {
31 | "build": "npm run build:cjs && npm run build:esm && npm run build:types",
32 | "build:cjs": "esbuild src/index.ts --sourcemap --bundle --external:react --format=cjs --outfile=dist-node/react-use-pagination.js",
33 | "build:esm": "esbuild src/index.ts --sourcemap --bundle --external:react --format=esm --outfile=dist-module/react-use-pagination.mjs",
34 | "build:types": "tsc --emitDeclarationOnly --declaration --declarationMap false --declarationDir dist-types",
35 | "lint": "eslint src --ext=ts,tsx",
36 | "prepack": "npm run build",
37 | "test": "jest src --coverage",
38 | "typecheck": "tsc --noEmit"
39 | },
40 | "peerDependencies": {
41 | "react": "^16.8.0 || ^17 || ^18"
42 | },
43 | "devDependencies": {
44 | "@erictooth/eslint-config": "^3",
45 | "@erictooth/prettier-config": "^4",
46 | "@erictooth/semantic-release-npm-github-config": "^1",
47 | "@testing-library/react-hooks": "^7.0.0",
48 | "@types/jest": "^26",
49 | "@types/react": "^17",
50 | "esbuild": "^0.12.9",
51 | "eslint": "^7",
52 | "jest": "^27",
53 | "prettier": "^2",
54 | "react-test-renderer": "^17.0.2",
55 | "ts-jest": "^27",
56 | "typescript": "^4"
57 | },
58 | "eslintConfig": {
59 | "extends": [
60 | "@erictooth/eslint-config",
61 | "@erictooth/eslint-config/typescript",
62 | "@erictooth/eslint-config/react"
63 | ]
64 | },
65 | "prettier": "@erictooth/prettier-config",
66 | "release": {
67 | "extends": "@erictooth/semantic-release-npm-github-config",
68 | "branches": [
69 | "master"
70 | ]
71 | },
72 | "jest": {
73 | "preset": "ts-jest",
74 | "collectCoverageFrom": [
75 | "src/**/{!(index),}.{ts, tsx}"
76 | ]
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 |
3 | import { usePagination } from "./usePagination";
4 |
5 | type PaginationProps = {
6 | children: (arg0: ReturnType) => ReactNode;
7 | totalItems?: number;
8 | initialPage?: number;
9 | initialPageSize: number;
10 | };
11 |
12 | function Pagination({
13 | children,
14 | totalItems = 0,
15 | initialPage = 0,
16 | initialPageSize,
17 | }: PaginationProps) {
18 | return children(usePagination({ totalItems, initialPage, initialPageSize }));
19 | }
20 |
21 | Pagination.displayName = "Pagination";
22 |
23 | export { Pagination };
24 |
--------------------------------------------------------------------------------
/src/__tests__/getPaginationMeta.test.ts:
--------------------------------------------------------------------------------
1 | import { getPaginationMeta, PaginationState } from "../getPaginationMeta";
2 |
3 | const MULTI_PAGE_FIRST_PAGE: PaginationState = {
4 | totalItems: 100,
5 | pageSize: 10,
6 | currentPage: 0,
7 | };
8 |
9 | describe("getPaginationMeta", () => {
10 | it("correctly calculates startIndex and lastIndex on the first page", () => {
11 | const meta = getPaginationMeta(MULTI_PAGE_FIRST_PAGE);
12 | expect(meta.startIndex).toBe(0);
13 | expect(meta.endIndex).toBe(9);
14 | });
15 |
16 | it("correctly calculates startIndex and endIndex on the second page", () => {
17 | const meta = getPaginationMeta({ ...MULTI_PAGE_FIRST_PAGE, currentPage: 1 });
18 | expect(meta.startIndex).toBe(10);
19 | expect(meta.endIndex).toBe(19);
20 | });
21 |
22 | it("correctly calculates startIndex and endIndex on the last page", () => {
23 | const meta = getPaginationMeta({ ...MULTI_PAGE_FIRST_PAGE, currentPage: 9 });
24 | expect(meta.startIndex).toBe(90);
25 | expect(meta.endIndex).toBe(99);
26 | });
27 |
28 | it("correctly calculates endIndex on a half-full last page", () => {
29 | const meta = getPaginationMeta({ totalItems: 92, pageSize: 10, currentPage: 9 });
30 | expect(meta.endIndex).toBe(91);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/__tests__/paginationStateReducer.test.ts:
--------------------------------------------------------------------------------
1 | import { paginationStateReducer } from "../paginationStateReducer";
2 | import { PaginationState } from "../getPaginationMeta";
3 |
4 | const MULTI_PAGE_FIRST_PAGE: PaginationState = {
5 | totalItems: 100,
6 | pageSize: 10,
7 | currentPage: 0,
8 | };
9 |
10 | describe("paginationStateReducer", () => {
11 | it("sets the next page when not on the last page", () => {
12 | const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, { type: "NEXT_PAGE" });
13 | expect(nextState.currentPage).toBe(1);
14 | });
15 |
16 | it("does not set the next page when on the last page", () => {
17 | const nextState = paginationStateReducer(
18 | { totalItems: 1, pageSize: 1, currentPage: 0 },
19 | { type: "NEXT_PAGE" }
20 | );
21 | expect(nextState.currentPage).toBe(0);
22 | });
23 |
24 | it("sets the previous page when not on the first page", () => {
25 | const nextState = paginationStateReducer(
26 | { totalItems: 2, pageSize: 1, currentPage: 1 },
27 | { type: "PREVIOUS_PAGE" }
28 | );
29 | expect(nextState.currentPage).toBe(0);
30 | });
31 |
32 | it("does not set the previous page when on the first page", () => {
33 | const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, { type: "PREVIOUS_PAGE" });
34 | expect(nextState.currentPage).toBe(0);
35 | });
36 |
37 | it("allows totalPages to be set", () => {
38 | const nextTotalItems = 12;
39 | const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, {
40 | type: "SET_TOTALITEMS",
41 | totalItems: nextTotalItems,
42 | });
43 |
44 | expect(nextState.totalItems).toBe(nextTotalItems);
45 | });
46 |
47 | it("allows pageSize to be set", () => {
48 | const nextPageSize = 12;
49 | const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, {
50 | type: "SET_PAGESIZE",
51 | pageSize: nextPageSize,
52 | });
53 |
54 | expect(nextState.pageSize).toBe(nextPageSize);
55 | });
56 |
57 | it("allows currentPage to be set", () => {
58 | const nextCurrentPage = 12;
59 | const nextState = paginationStateReducer(
60 | { totalItems: 100, pageSize: 1, currentPage: 0 },
61 | {
62 | type: "SET_PAGE",
63 | page: nextCurrentPage,
64 | }
65 | );
66 |
67 | expect(nextState.currentPage).toBe(nextCurrentPage);
68 | });
69 |
70 | it("disallows currentPage from being set below 0", () => {
71 | const nextCurrentPage = -1;
72 | const nextState = paginationStateReducer(MULTI_PAGE_FIRST_PAGE, {
73 | type: "SET_PAGE",
74 | page: nextCurrentPage,
75 | });
76 |
77 | expect(nextState.currentPage).toBe(0);
78 | });
79 |
80 | it("disallows currentPage from being set above totalPages", () => {
81 | const nextCurrentPage = 1;
82 | const nextState = paginationStateReducer(
83 | { totalItems: 1, pageSize: 1, currentPage: 0 },
84 | {
85 | type: "SET_PAGE",
86 | page: nextCurrentPage,
87 | }
88 | );
89 |
90 | expect(nextState.currentPage).toBe(0);
91 | });
92 |
93 | it("limits currentPage within totalPages when pageSize is increased", () => {
94 | const nextState = paginationStateReducer(
95 | { totalItems: 100, pageSize: 10, currentPage: 9 },
96 | {
97 | type: "SET_PAGESIZE",
98 | pageSize: 50,
99 | }
100 | );
101 |
102 | expect(nextState.currentPage).toBe(1);
103 | });
104 |
105 | it("doesn't change currentPage if it wouldn't be out of bounds when pageSize is increased", () => {
106 | const nextState = paginationStateReducer(
107 | { totalItems: 100, pageSize: 10, currentPage: 2 },
108 | {
109 | type: "SET_PAGESIZE",
110 | pageSize: 25,
111 | }
112 | );
113 |
114 | expect(nextState.currentPage).toBe(2);
115 | });
116 |
117 | it("limits currentPage within totalPages when totalItems is decreased", () => {
118 | const nextState = paginationStateReducer(
119 | { totalItems: 100, pageSize: 10, currentPage: 9 },
120 | {
121 | type: "SET_TOTALITEMS",
122 | totalItems: 20,
123 | }
124 | );
125 |
126 | expect(nextState.currentPage).toBe(1);
127 | });
128 |
129 | it("doesn't change currentPage if it wouldn't be out of bounds when totalItems is decreased", () => {
130 | const nextState = paginationStateReducer(
131 | { totalItems: 100, pageSize: 10, currentPage: 9 },
132 | {
133 | type: "SET_TOTALITEMS",
134 | totalItems: 95,
135 | }
136 | );
137 |
138 | expect(nextState.currentPage).toBe(9);
139 | });
140 | });
141 |
--------------------------------------------------------------------------------
/src/__tests__/usePagination.test.ts:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from "@testing-library/react-hooks";
2 | import { usePagination } from "../usePagination";
3 |
4 | const DEFAULT_STATE = {
5 | totalItems: 100,
6 | initialPageSize: 10,
7 | initialPage: 1,
8 | };
9 |
10 | describe("usePagination", () => {
11 | it("correctly initializes the page state and contains the expected metadata", () => {
12 | const { result } = renderHook(() => usePagination(DEFAULT_STATE));
13 |
14 | expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
15 | expect(result.current.totalItems).toBe(DEFAULT_STATE.totalItems);
16 | expect(result.current.pageSize).toBe(DEFAULT_STATE.initialPageSize);
17 | expect(result.current.startIndex).toBe(
18 | DEFAULT_STATE.initialPage * DEFAULT_STATE.initialPageSize
19 | );
20 | expect(result.current.endIndex).toBe(
21 | (DEFAULT_STATE.initialPage + 1) * DEFAULT_STATE.initialPageSize - 1
22 | );
23 | expect(result.current.nextEnabled).toBe(true);
24 | expect(result.current.previousEnabled).toBe(true);
25 | });
26 |
27 | it("sets the next page when setNextPage is called", () => {
28 | const { result } = renderHook(usePagination, { initialProps: DEFAULT_STATE });
29 |
30 | expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
31 |
32 | act(() => {
33 | result.current.setNextPage();
34 | });
35 |
36 | expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage + 1);
37 | });
38 |
39 | it("sets the previous page when setPreviousPage is called", () => {
40 | const { result } = renderHook(usePagination, { initialProps: DEFAULT_STATE });
41 |
42 | expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
43 |
44 | act(() => {
45 | result.current.setPreviousPage();
46 | });
47 |
48 | expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage - 1);
49 | });
50 |
51 | it("sets the page when setPage is called", () => {
52 | const { result } = renderHook(usePagination, { initialProps: DEFAULT_STATE });
53 |
54 | expect(result.current.currentPage).toBe(DEFAULT_STATE.initialPage);
55 |
56 | act(() => {
57 | result.current.setPage(0);
58 | });
59 |
60 | expect(result.current.currentPage).toBe(0);
61 | });
62 |
63 | it("sets the pageSize when setPageSize is called", () => {
64 | const { result } = renderHook(usePagination, { initialProps: DEFAULT_STATE });
65 |
66 | expect(result.current.pageSize).toBe(DEFAULT_STATE.initialPageSize);
67 |
68 | act(() => {
69 | result.current.setPageSize(5);
70 | });
71 |
72 | expect(result.current.pageSize).toBe(5);
73 | });
74 |
75 | // This is required so that the hook can be rendered before server-provided data is available
76 | it("initializes configurations to 0 when not provided", () => {
77 | const { result } = renderHook(usePagination, { initialProps: undefined });
78 |
79 | expect(result.current.totalItems).toBe(0);
80 | expect(result.current.pageSize).toBe(0);
81 | expect(result.current.currentPage).toBe(0);
82 | });
83 |
84 | it("updates the totalItems in the reducer when re-rendered", async () => {
85 | const { result, rerender } = renderHook(usePagination, { initialProps: undefined });
86 | expect(result.current.totalItems).toBe(0);
87 |
88 | act(() => {
89 | rerender({ totalItems: 123 });
90 | });
91 |
92 | expect(result.current.totalItems).toBe(123);
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/src/getPaginationMeta.ts:
--------------------------------------------------------------------------------
1 | export const getPreviousEnabled = (currentPage: number): boolean => currentPage > 0;
2 |
3 | export const getNextEnabled = (currentPage: number, totalPages: number): boolean =>
4 | currentPage + 1 < totalPages;
5 |
6 | export const getTotalPages = (totalItems: number, pageSize: number): number =>
7 | Math.ceil(totalItems / pageSize);
8 |
9 | export const getStartIndex = (pageSize: number, currentPage: number): number =>
10 | pageSize * currentPage;
11 |
12 | export const getEndIndex = (pageSize: number, currentPage: number, totalItems: number): number => {
13 | const lastPageEndIndex = pageSize * (currentPage + 1);
14 |
15 | if (lastPageEndIndex > totalItems) {
16 | return totalItems - 1;
17 | }
18 |
19 | return lastPageEndIndex - 1;
20 | };
21 |
22 | export const limitPageBounds =
23 | (totalItems: number, pageSize: number) =>
24 | (page: number): number =>
25 | Math.min(Math.max(page, 0), getTotalPages(totalItems, pageSize) - 1);
26 |
27 | export type PaginationState = {
28 | totalItems: number;
29 | pageSize: number;
30 | currentPage: number;
31 | };
32 |
33 | export type PaginationMeta = {
34 | totalPages: number;
35 | startIndex: number;
36 | endIndex: number;
37 | previousEnabled: boolean;
38 | nextEnabled: boolean;
39 | };
40 |
41 | export const getPaginationMeta = ({
42 | totalItems,
43 | pageSize,
44 | currentPage,
45 | }: PaginationState): PaginationMeta => {
46 | const totalPages = getTotalPages(totalItems, pageSize);
47 | return {
48 | totalPages,
49 | startIndex: getStartIndex(pageSize, currentPage),
50 | endIndex: getEndIndex(pageSize, currentPage, totalItems),
51 | previousEnabled: getPreviousEnabled(currentPage),
52 | nextEnabled: getNextEnabled(currentPage, totalPages),
53 | };
54 | };
55 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./getPaginationMeta";
2 | export * from "./Pagination";
3 | export * from "./usePagination";
4 |
--------------------------------------------------------------------------------
/src/paginationStateReducer.ts:
--------------------------------------------------------------------------------
1 | import { limitPageBounds, PaginationState } from "./getPaginationMeta";
2 |
3 | type CurrentPageActions =
4 | | { type: "NEXT_PAGE" }
5 | | { type: "PREVIOUS_PAGE" }
6 | | { type: "SET_PAGE"; page: number };
7 |
8 | type TotalItemsActions = {
9 | type: "SET_TOTALITEMS";
10 | totalItems: number;
11 | nextPage?: number;
12 | };
13 |
14 | type PageSizeActions = {
15 | type: "SET_PAGESIZE";
16 | pageSize: number;
17 | nextPage?: number;
18 | };
19 |
20 | type PaginationStateReducerActions = CurrentPageActions | TotalItemsActions | PageSizeActions;
21 |
22 | const getCurrentPageReducer = (rootState: PaginationState) =>
23 | function currentPageReducer(
24 | state: PaginationState["currentPage"],
25 | action: PaginationStateReducerActions
26 | ) {
27 | switch (action.type) {
28 | case "SET_PAGE":
29 | return limitPageBounds(rootState.totalItems, rootState.pageSize)(action.page);
30 | case "NEXT_PAGE":
31 | return limitPageBounds(rootState.totalItems, rootState.pageSize)(state + 1);
32 | case "PREVIOUS_PAGE":
33 | return limitPageBounds(rootState.totalItems, rootState.pageSize)(state - 1);
34 | case "SET_PAGESIZE":
35 | return limitPageBounds(
36 | rootState.totalItems,
37 | action.pageSize
38 | )(action.nextPage ?? state);
39 | case "SET_TOTALITEMS":
40 | return limitPageBounds(
41 | action.totalItems,
42 | rootState.pageSize
43 | )(action.nextPage ?? state);
44 | /* istanbul ignore next */
45 | default:
46 | return state;
47 | }
48 | };
49 |
50 | function totalItemsReducer(state: PaginationState["totalItems"], action: TotalItemsActions) {
51 | switch (action.type) {
52 | case "SET_TOTALITEMS":
53 | return action.totalItems;
54 | default:
55 | return state;
56 | }
57 | }
58 |
59 | function pageSizeReducer(state: PaginationState["pageSize"], action: PageSizeActions) {
60 | switch (action.type) {
61 | case "SET_PAGESIZE":
62 | return action.pageSize;
63 | default:
64 | return state;
65 | }
66 | }
67 |
68 | export function paginationStateReducer(
69 | state: PaginationState,
70 | action: PaginationStateReducerActions
71 | ): PaginationState {
72 | return {
73 | currentPage: getCurrentPageReducer(state)(state.currentPage, action as CurrentPageActions),
74 | totalItems: totalItemsReducer(state.totalItems, action as TotalItemsActions),
75 | pageSize: pageSizeReducer(state.pageSize, action as PageSizeActions),
76 | };
77 | }
78 |
--------------------------------------------------------------------------------
/src/usePagination.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useMemo, useRef, useReducer } from "react";
2 | import { getPaginationMeta, PaginationState, PaginationMeta } from "./getPaginationMeta";
3 | import { paginationStateReducer } from "./paginationStateReducer";
4 |
5 | type UsePaginationConfig = {
6 | totalItems?: number;
7 | initialPage?: number;
8 | initialPageSize?: number;
9 | };
10 |
11 | type PaginationActions = {
12 | setPage: (page: number) => void;
13 | setNextPage: () => void;
14 | setPreviousPage: () => void;
15 | setPageSize: (pageSize: number, nextPage?: number) => void;
16 | };
17 |
18 | export function usePagination({
19 | totalItems = 0,
20 | initialPage = 0,
21 | initialPageSize = 0,
22 | }: UsePaginationConfig = {}): PaginationState & PaginationMeta & PaginationActions {
23 | const initialState = {
24 | totalItems,
25 | pageSize: initialPageSize,
26 | currentPage: initialPage,
27 | };
28 |
29 | const [paginationState, dispatch] = useReducer(paginationStateReducer, initialState);
30 |
31 | const totalItemsRef = useRef(totalItems);
32 | totalItemsRef.current = totalItems;
33 |
34 | useEffect(() => {
35 | return () => {
36 | if (typeof totalItemsRef.current !== "number" || totalItems === totalItemsRef.current) {
37 | return;
38 | }
39 |
40 | dispatch({ type: "SET_TOTALITEMS", totalItems: totalItemsRef.current });
41 | };
42 | }, [totalItems]);
43 |
44 | return {
45 | ...paginationState,
46 | ...useMemo(() => getPaginationMeta(paginationState), [paginationState]),
47 | setPage: useCallback((page: number) => {
48 | dispatch({
49 | type: "SET_PAGE",
50 | page,
51 | });
52 | }, []),
53 | setNextPage: useCallback(() => {
54 | dispatch({ type: "NEXT_PAGE" });
55 | }, []),
56 | setPreviousPage: useCallback(() => {
57 | dispatch({ type: "PREVIOUS_PAGE" });
58 | }, []),
59 | setPageSize: useCallback((pageSize: number, nextPage = 0) => {
60 | dispatch({ type: "SET_PAGESIZE", pageSize, nextPage });
61 | }, []),
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src"],
3 | "compilerOptions": {
4 | "target": "ES2020",
5 | "strict": true,
6 | "moduleResolution": "node",
7 | "esModuleInterop": true,
8 | "jsx": "react"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------