├── .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 | react-use-pagination 3 |
4 | 5 |

A React hook to help manage pagination state

6 | 7 |

8 | 9 | MIT License 10 | 11 | 12 | 13 | 14 | 15 | Code Style: Prettier 16 | 17 | 100% coverage 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 | 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 | 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 | --------------------------------------------------------------------------------