├── .all-contributorsrc ├── .github ├── composite-actions │ └── install │ │ └── action.yml └── workflows │ ├── nextjs.yml │ └── quality.yml ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps └── demo │ ├── .eslintrc.js │ ├── README.md │ ├── lint-staged.config.mjs │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── horizontal-element-scroll │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── reverse-horizontal-element-scroll │ │ │ └── page.tsx │ │ ├── reverse-vertical-element-scroll │ │ │ └── page.tsx │ │ ├── reverse-window-scroll │ │ │ └── page.tsx │ │ ├── vertical-element-scroll │ │ │ └── page.tsx │ │ └── window-scroll │ │ │ └── page.tsx │ ├── components │ │ ├── header.tsx │ │ ├── layout.tsx │ │ ├── list.tsx │ │ └── page-title.tsx │ ├── lib │ │ └── utils.ts │ └── styles │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── lint-staged.config.mjs ├── package-lock.json ├── package.json ├── packages ├── eslint-config │ ├── README.md │ ├── next.js │ ├── package.json │ ├── prettier.js │ ├── react-library.js │ ├── typescript.js │ └── unicorn.js ├── lint-staged-config │ ├── generic.mjs │ ├── next.mjs │ └── package.json ├── react-infinite-scroll-hook │ ├── .eslintrc.js │ ├── LICENSE │ ├── README.md │ ├── lint-staged.config.mjs │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── use-infinite-scroll.ts │ ├── tsconfig.json │ └── tsup.config.ts └── typescript-config │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json ├── prettier.config.mjs ├── tsconfig.json └── turbo.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-infinite-scroll-hook", 3 | "projectOwner": "onderonur", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": ["./packages/react-infinite-scroll-hook/README.md"], 7 | "imageSize": 100, 8 | "commit": true, 9 | "commitConvention": "none", 10 | "contributors": [ 11 | { 12 | "login": "eugef", 13 | "name": "Eugene Fidelin", 14 | "avatar_url": "https://avatars0.githubusercontent.com/u/895071?v=4", 15 | "profile": "https://nl.linkedin.com/in/eugef", 16 | "contributions": ["code"] 17 | }, 18 | { 19 | "login": "Evanc123", 20 | "name": "Evan Cater", 21 | "avatar_url": "https://avatars.githubusercontent.com/u/4010547?v=4", 22 | "profile": "https://github.com/Evanc123", 23 | "contributions": ["doc"] 24 | }, 25 | { 26 | "login": "groomain", 27 | "name": "Romain", 28 | "avatar_url": "https://avatars.githubusercontent.com/u/3601848?v=4", 29 | "profile": "https://github.com/groomain", 30 | "contributions": ["example"] 31 | } 32 | ], 33 | "contributorsPerLine": 7, 34 | "linkToUsage": true 35 | } 36 | -------------------------------------------------------------------------------- /.github/composite-actions/install/action.yml: -------------------------------------------------------------------------------- 1 | name: Install 2 | description: Sets up Node.js and runs install 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup Node.js 8 | uses: actions/setup-node@v4 9 | with: 10 | node-version-file: '.nvmrc' 11 | cache: npm 12 | 13 | - name: Install dependencies 14 | shell: bash 15 | run: npm ci 16 | -------------------------------------------------------------------------------- /.github/workflows/nextjs.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/actions/starter-workflows/blob/main/pages/nextjs.yml 2 | # Sample workflow for building and deploying a Next.js site to GitHub Pages 3 | # 4 | # To get started with Next.js see: https://nextjs.org/docs/getting-started 5 | # 6 | name: Deploy Next.js site to Pages 7 | 8 | on: 9 | # Runs when `Quality` workflow gets completed on `main` branch 10 | workflow_run: 11 | branches: ['main'] 12 | workflows: ['Quality'] 13 | types: 14 | - completed 15 | # Allows you to run this workflow manually from the Actions tab 16 | workflow_dispatch: 17 | 18 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 19 | permissions: 20 | contents: read 21 | pages: write 22 | id-token: write 23 | 24 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 25 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 26 | concurrency: 27 | group: 'pages' 28 | cancel-in-progress: false 29 | 30 | jobs: 31 | # Build job 32 | build: 33 | # Since `workflow_run` only has 3 activity types (`completed`, `requested` and `in_progress`), 34 | # even if `Quality` workflow gets completed by failing, this workflow starts to run. 35 | # To prevent this, we add a condition here. 36 | # https://github.com/orgs/community/discussions/26238#discussioncomment-3250901 37 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v5 44 | - name: Install 45 | uses: ./.github/composite-actions/install 46 | - name: Build with Next.js 47 | run: npm run build -- --filter=demo 48 | - name: Upload artifact 49 | uses: actions/upload-pages-artifact@v3 50 | with: 51 | path: ./apps/demo/out 52 | 53 | # Deployment job 54 | deploy: 55 | environment: 56 | name: github-pages 57 | url: ${{ steps.deployment.outputs.page_url }} 58 | runs-on: ubuntu-latest 59 | needs: build 60 | steps: 61 | - name: Deploy to GitHub Pages 62 | id: deployment 63 | uses: actions/deploy-pages@v4 64 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Quality 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # To cancel previous workflow when a new one is triggered. 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | format: 18 | name: Format 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: Check out 22 | uses: actions/checkout@v4 23 | - name: Install 24 | uses: ./.github/composite-actions/install 25 | - name: Run format check 26 | run: npm run format 27 | 28 | lint: 29 | name: Lint 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Check out 33 | uses: actions/checkout@v4 34 | - name: Install 35 | uses: ./.github/composite-actions/install 36 | - name: Run lint check 37 | run: npm run lint 38 | 39 | typecheck: 40 | name: Typecheck 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Check out 44 | uses: actions/checkout@v4 45 | - name: Install 46 | uses: ./.github/composite-actions/install 47 | - name: Run type check 48 | run: npm run typecheck 49 | 50 | build: 51 | name: Build 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Check out 55 | uses: actions/checkout@v4 56 | - name: Install 57 | uses: ./.github/composite-actions/install 58 | - name: Run build 59 | run: npm run build 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | # Debug 31 | npm-debug.log* 32 | yarn-debug.log* 33 | yarn-error.log* 34 | 35 | # Misc 36 | .DS_Store 37 | *.pem 38 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | npm run typecheck 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | Plesae check [Releases](https://github.com/onderonur/react-infinite-scroll-hook/releases) page for migration instructions. 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This repository is created by using [Turborepo](https://turbo.build/repo). To understand the repository structure better, please check its [documentation](https://turbo.build/repo/docs). 4 | 5 | ## Local Development 6 | 7 | After cloning the repository, we need to install the dependencies. 8 | 9 | ```bash 10 | npm install 11 | ``` 12 | 13 | To start the demo Next.js app which uses the local version of `react-infinite-scroll-hook`, we can run `dev` script. 14 | 15 | ```bash 16 | npm run dev 17 | ``` 18 | 19 | After this, we can open `http://localhost:3000` in the browser to display the app. 20 | 21 | ## Code Quality Checks 22 | 23 | We use automated checks by using [ESLint](https://eslint.org/), [Prettier](https://prettier.io/) and [TypeScript](https://www.typescriptlang.org/) to provide the highest quality code as it can be. 24 | 25 | All checks are run automatically before committing by using [husky](https://github.com/typicode/husky) and [lint-staged](https://github.com/lint-staged/lint-staged). 26 | 27 | The checks can be run manually by running the below command too. 28 | 29 | ```bash 30 | npm run codequality 31 | ``` 32 | 33 | And the same checks can be run also by enabling fixes for auto-fixable issues. 34 | 35 | ```bash 36 | npm run codequality:fix 37 | ``` 38 | 39 | `codequality` scripts run underlying ESLint (`lint`), Prettier (`format`) and TypeScript (`types`) scripts. To run these tools individually, we can also use the below scripts. 40 | 41 | ```bash 42 | # ESLint checks 43 | npm run lint 44 | # ESLint fixes 45 | npm run lint:fix 46 | 47 | # Prettier checks 48 | npm run format 49 | # Prettier fixes 50 | npm run format:fix 51 | 52 | # TypeScript checks 53 | npm run typecheck 54 | # There is no auto-fix script for TypeScript. 55 | ``` 56 | 57 | ## Updating Dependencies 58 | 59 | We use `npm-check-updates` package to automatically check if there are newer versions of our dependencies. 60 | 61 | To run it, we can use the below command. It starts an interactive CLI to check the dependencies of all the apps and packages, including the root dependencies. 62 | 63 | ```bash 64 | npm run updates 65 | ``` 66 | 67 | ## Adding Contributors 68 | 69 | [all-contributors-cli](https://github.com/all-contributors/cli) is used for maintaining the contributors of this repository. 70 | 71 | To add a new contributor, we can run the below command and follow its instructions. 72 | 73 | ```bash 74 | npm run contributors:add 75 | ``` 76 | 77 | ## Prepublish Checks 78 | 79 | To be sure everything is OK with the latest changes, we can use [publint](https://publint.dev/) and [Are the Types Wrong](https://github.com/arethetypeswrong/arethetypeswrong.github.io). 80 | 81 | Firstly, we need to build the bundle with the latest changes. 82 | 83 | ```bash 84 | npm run build:bundle 85 | ``` 86 | 87 | This command will create (or update) the `packages/react-infinite-scroll-hook/dist` folder, which will be used by the clients of this package. 88 | 89 | To be sure the output is OK for ESM and CJS clients, we can run the below commands and check their outputs. 90 | 91 | ```bash 92 | # For `publint` 93 | npm run publint -w react-infinite-scroll-hook 94 | 95 | # For `Are the Types Wrong` 96 | npm run attw -w react-infinite-scroll-hook 97 | ``` 98 | 99 | To see the content of the package which can be uploaded to [npm](https://www.npmjs.com/) can be seen by using the below command. It will create a tarball from `react-infinite-scroll-hook` package. 100 | 101 | ```bash 102 | npm pack -w react-infinite-scroll-hook 103 | ``` 104 | 105 | Or the below command can be used to only check the tarball contents without creating it. 106 | 107 | ```bash 108 | npm pack --dry-run -w react-infinite-scroll-hook 109 | ``` 110 | 111 | Lastly, we can run the below command to auto correct common errors in `package.json` of the package to be published. `npm publish` command already does these auto-fixes too. 112 | 113 | ```bash 114 | npm pkg fix -w react-infinite-scroll-hook 115 | ``` 116 | 117 | ## Publishing the Package 118 | 119 | Firstly, we need to bump the package version which can be done by using the below commands. 120 | 121 | ```bash 122 | npm version patch -w react-infinite-scroll-hook 123 | # Bumps the patch number like 0.0.0 -> 0.0.1 124 | 125 | npm version minor -w react-infinite-scroll-hook 126 | # Bumps the patch number like 0.0.x -> 0.1.0 127 | 128 | npm version major -w react-infinite-scroll-hook 129 | # Bumps the patch number like 0.x.y -> 1.0.0 130 | ``` 131 | 132 | And we can publish the new version now 🚀 133 | 134 | ```bash 135 | npm publish -w react-infinite-scroll-hook 136 | ``` 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 onderonur 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/react-infinite-scroll-hook/README.md -------------------------------------------------------------------------------- /apps/demo/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ['@repo/eslint-config/next.js'], 5 | parser: '@typescript-eslint/parser', 6 | parserOptions: { 7 | project: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /apps/demo/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /apps/demo/lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@repo/lint-staged-config/next.mjs'; 2 | -------------------------------------------------------------------------------- /apps/demo/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/demo/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: 'export', 4 | basePath: '/react-infinite-scroll-hook', 5 | images: { 6 | unoptimized: true, 7 | }, 8 | eslint: { 9 | dirs: [ 10 | 'src', 11 | 'lint-staged.config.mjs', 12 | 'postcss.config.js', 13 | 'tailwind.config.ts', 14 | ], 15 | }, 16 | }; 17 | 18 | export default nextConfig; 19 | -------------------------------------------------------------------------------- /apps/demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint --max-warnings 0", 9 | "lint:fix": "next lint --fix --max-warnings 0", 10 | "start": "next start", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "next": "15.3.1", 15 | "react": "^19.1.0", 16 | "react-dom": "^19.1.0", 17 | "react-infinite-scroll-hook": "*" 18 | }, 19 | "devDependencies": { 20 | "@repo/eslint-config": "*", 21 | "@repo/lint-staged-config": "*", 22 | "@repo/typescript-config": "*", 23 | "@types/node": "^22.15.3", 24 | "@types/react": "^19.1.2", 25 | "@types/react-dom": "^19.1.3", 26 | "autoprefixer": "^10.4.21", 27 | "eslint": "^8.57.0", 28 | "eslint-config-next": "14.2.5", 29 | "postcss": "^8.5.3", 30 | "tailwindcss": "^3.4.10", 31 | "typescript": "^5.8.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/demo/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onderonur/react-infinite-scroll-hook/2ad3828e05345def748dfa2cb93d78aff423ebb6/apps/demo/src/app/favicon.ico -------------------------------------------------------------------------------- /apps/demo/src/app/horizontal-element-scroll/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 4 | import { List, ListItem, Loading } from '../../components/list'; 5 | import { PageTitle } from '../../components/page-title'; 6 | import { useLoadItems } from '../../lib/utils'; 7 | 8 | export default function HorizontalElementScrollPage() { 9 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 10 | 11 | const [infiniteRef, { rootRef }] = useInfiniteScroll({ 12 | loading, 13 | hasNextPage, 14 | onLoadMore: loadMore, 15 | disabled: Boolean(error), 16 | }); 17 | 18 | return ( 19 |
20 | 21 | Horizontal Element Scroll 22 | 23 |
27 | 28 | {items.map((item) => ( 29 | {item.value} 30 | ))} 31 | 32 | {hasNextPage && } 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/demo/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter } from 'next/font/google'; 3 | import { Header } from '../components/header'; 4 | import { Layout } from '../components/layout'; 5 | import '../styles/globals.css'; 6 | 7 | const inter = Inter({ subsets: ['latin'] }); 8 | 9 | export const metadata: Metadata = { 10 | title: 'react-infinite-scroll-hook', 11 | description: 'Demo app to showcase react-infinite-scroll-hook usage', 12 | }; 13 | 14 | type RootLayoutProps = { 15 | children: React.ReactNode; 16 | }; 17 | 18 | export default function RootLayout({ children }: RootLayoutProps) { 19 | return ( 20 | 21 | 22 | 23 |
24 |
{children}
25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/demo/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { PageTitle } from '../components/page-title'; 3 | 4 | export default function HomePage() { 5 | return ( 6 |
7 | Home 8 |

9 | Select one of the options to see the live demo and their source code. 10 |

11 |

12 | This is a very simple demo website for `react-infinite-scroll-hook` only 13 | to have some live demos for users. 14 |

15 |

16 | You can check the full source code and docs{' '} 17 | 21 | here 22 | 23 | . 24 |

25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/demo/src/app/reverse-horizontal-element-scroll/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; 4 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 5 | import { List, ListItem, Loading } from '../../components/list'; 6 | import { PageTitle } from '../../components/page-title'; 7 | import { useLoadItems } from '../../lib/utils'; 8 | 9 | export default function ReverseHorizontalElementScrollPage() { 10 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 11 | 12 | const [infiniteRef, { rootRef }] = useInfiniteScroll({ 13 | loading, 14 | hasNextPage, 15 | onLoadMore: loadMore, 16 | disabled: Boolean(error), 17 | }); 18 | 19 | const scrollableRootRef = useRef | null>(null); 20 | const lastScrollDistanceToRightRef = useRef(0); 21 | 22 | const reversedItems = useMemo(() => [...items].reverse(), [items]); 23 | 24 | // We keep the scroll position when new items are added etc. 25 | useLayoutEffect(() => { 26 | const scrollableRoot = scrollableRootRef.current; 27 | const lastScrollDistanceToRight = lastScrollDistanceToRightRef.current; 28 | if (scrollableRoot) { 29 | scrollableRoot.scrollLeft = 30 | scrollableRoot.scrollWidth - lastScrollDistanceToRight; 31 | } 32 | }, [reversedItems, rootRef]); 33 | 34 | const rootRefSetter = useCallback( 35 | (node: HTMLDivElement) => { 36 | rootRef(node); 37 | scrollableRootRef.current = node; 38 | }, 39 | [rootRef], 40 | ); 41 | 42 | const handleRootScroll = useCallback(() => { 43 | const rootNode = scrollableRootRef.current; 44 | if (rootNode) { 45 | const scrollDistanceToRight = rootNode.scrollWidth - rootNode.scrollLeft; 46 | lastScrollDistanceToRightRef.current = scrollDistanceToRight; 47 | } 48 | }, []); 49 | 50 | return ( 51 |
52 | 53 | Reverse Horizontal Element Scroll 54 | 55 |
60 | {hasNextPage && } 61 | 62 | {reversedItems.map((item) => ( 63 | {item.value} 64 | ))} 65 | 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /apps/demo/src/app/reverse-vertical-element-scroll/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; 4 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 5 | import { List, ListItem, Loading } from '../../components/list'; 6 | import { PageTitle } from '../../components/page-title'; 7 | import { useLoadItems } from '../../lib/utils'; 8 | 9 | export default function ReverseVerticalElementScrollPage() { 10 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 11 | 12 | const [infiniteRef, { rootRef }] = useInfiniteScroll({ 13 | loading, 14 | hasNextPage, 15 | onLoadMore: loadMore, 16 | disabled: Boolean(error), 17 | }); 18 | 19 | const scrollableRootRef = useRef | null>(null); 20 | const lastScrollDistanceToBottomRef = useRef(0); 21 | 22 | const reversedItems = useMemo(() => [...items].reverse(), [items]); 23 | 24 | // We keep the scroll position when new items are added etc. 25 | useLayoutEffect(() => { 26 | const scrollableRoot = scrollableRootRef.current; 27 | const lastScrollDistanceToBottom = lastScrollDistanceToBottomRef.current; 28 | if (scrollableRoot) { 29 | scrollableRoot.scrollTop = 30 | scrollableRoot.scrollHeight - lastScrollDistanceToBottom; 31 | } 32 | }, [reversedItems, rootRef]); 33 | 34 | const rootRefSetter = useCallback( 35 | (node: HTMLDivElement) => { 36 | rootRef(node); 37 | scrollableRootRef.current = node; 38 | }, 39 | [rootRef], 40 | ); 41 | 42 | const handleRootScroll = useCallback(() => { 43 | const rootNode = scrollableRootRef.current; 44 | if (rootNode) { 45 | const scrollDistanceToBottom = rootNode.scrollHeight - rootNode.scrollTop; 46 | lastScrollDistanceToBottomRef.current = scrollDistanceToBottom; 47 | } 48 | }, []); 49 | 50 | return ( 51 |
52 | 53 | Reverse Vertical Element Scroll 54 | 55 |
60 | {hasNextPage && } 61 | 62 | {reversedItems.map((item) => ( 63 | {item.value} 64 | ))} 65 | 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /apps/demo/src/app/reverse-window-scroll/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; 4 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 5 | import { List, ListItem, Loading } from '../../components/list'; 6 | import { PageTitle } from '../../components/page-title'; 7 | import { useLoadItems } from '../../lib/utils'; 8 | 9 | export default function ReverseWindowScrollPage() { 10 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 11 | 12 | const [infiniteRef] = useInfiniteScroll({ 13 | loading, 14 | hasNextPage, 15 | onLoadMore: loadMore, 16 | disabled: Boolean(error), 17 | }); 18 | 19 | const lastScrollDistanceToBottomRef = useRef(0); 20 | 21 | const reversedItems = useMemo(() => [...items].reverse(), [items]); 22 | 23 | // We keep the scroll position when new items are added etc. 24 | useLayoutEffect(() => { 25 | const lastScrollDistanceToBottom = lastScrollDistanceToBottomRef.current; 26 | 27 | document.documentElement.scrollTop = 28 | document.body.scrollHeight - lastScrollDistanceToBottom; 29 | }, [reversedItems]); 30 | 31 | useEffect(() => { 32 | function handleScroll() { 33 | const rootNode = document.documentElement; 34 | const scrollDistanceToBottom = rootNode.scrollHeight - rootNode.scrollTop; 35 | lastScrollDistanceToBottomRef.current = scrollDistanceToBottom; 36 | } 37 | 38 | window.addEventListener('scroll', handleScroll); 39 | 40 | return () => { 41 | window.removeEventListener('scroll', handleScroll); 42 | }; 43 | }, []); 44 | 45 | return ( 46 |
47 | {hasNextPage && } 48 | 49 | {reversedItems.map((item) => ( 50 | {item.value} 51 | ))} 52 | 53 | 54 | Reverse Window Scroll 55 | 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/demo/src/app/vertical-element-scroll/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 4 | import { List, ListItem, Loading } from '../../components/list'; 5 | import { PageTitle } from '../../components/page-title'; 6 | import { useLoadItems } from '../../lib/utils'; 7 | 8 | export default function VerticalElementScrollPage() { 9 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 10 | 11 | const [infiniteRef, { rootRef }] = useInfiniteScroll({ 12 | loading, 13 | hasNextPage, 14 | onLoadMore: loadMore, 15 | disabled: Boolean(error), 16 | }); 17 | 18 | return ( 19 |
20 | 21 | Vertical Element Scroll 22 | 23 |
27 | 28 | {items.map((item) => ( 29 | {item.value} 30 | ))} 31 | 32 | {hasNextPage && } 33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/demo/src/app/window-scroll/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 4 | import { List, ListItem, Loading } from '../../components/list'; 5 | import { PageTitle } from '../../components/page-title'; 6 | import { useLoadItems } from '../../lib/utils'; 7 | 8 | export default function WindowScrollPage() { 9 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 10 | 11 | const [infiniteRef] = useInfiniteScroll({ 12 | loading, 13 | hasNextPage, 14 | onLoadMore: loadMore, 15 | // When there is an error, we stop infinite loading. 16 | // It can be reactivated by setting "error" state as undefined. 17 | disabled: Boolean(error), 18 | // `rootMargin` is passed to `IntersectionObserver`. 19 | // We can use it to trigger 'onLoadMore' when the sentry comes near to become 20 | // visible, instead of becoming fully visible on the screen. 21 | rootMargin: '0px 0px 400px 0px', 22 | }); 23 | 24 | return ( 25 | <> 26 |
27 | 28 | Window Scroll 29 | 30 | 31 | {items.map((item) => ( 32 | {item.value} 33 | ))} 34 | 35 | {hasNextPage && } 36 |
37 | {/* This is just for demonstration purposes. 38 | It may not be a good idea to put content below a full page infinite scroll list. */} 39 |
Footer
40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /apps/demo/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Link from 'next/link'; 4 | import { usePathname, useRouter } from 'next/navigation'; 5 | 6 | export function Header() { 7 | const router = useRouter(); 8 | const pathname = usePathname(); 9 | 10 | return ( 11 |
12 |
13 | 14 | Infinite List 15 | 16 |

17 | Created by using `react-infinite-scroll-hook` 18 |

19 |
20 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /apps/demo/src/components/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { usePathname } from 'next/navigation'; 4 | 5 | type LayoutProps = { 6 | children: React.ReactNode; 7 | }; 8 | 9 | export function Layout({ children }: LayoutProps) { 10 | const pathname = usePathname(); 11 | 12 | return ( 13 |
16 | {children} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/demo/src/components/list.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from 'react'; 2 | 3 | type ListProps = { 4 | direction?: 'vertical' | 'horizontal'; 5 | children: React.ReactNode; 6 | }; 7 | 8 | export function List({ direction, ...rest }: ListProps) { 9 | return ( 10 |
    14 | ); 15 | } 16 | 17 | type ListItemProps = { 18 | children: React.ReactNode; 19 | }; 20 | 21 | export function ListItem(props: ListItemProps) { 22 | return
  • ; 23 | } 24 | 25 | export const Loading = forwardRef, unknown>( 26 | function Loading(props, ref) { 27 | return ( 28 |
    29 |
    30 | Loading... 31 |
    32 |
    33 | ); 34 | }, 35 | ); 36 | -------------------------------------------------------------------------------- /apps/demo/src/components/page-title.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | type PageTitleProps = { 4 | filePath?: string; 5 | children: React.ReactNode; 6 | }; 7 | 8 | export function PageTitle({ filePath, children }: PageTitleProps) { 9 | return ( 10 |
    11 |

    {children}

    12 | {filePath && ( 13 | 19 | Source Code 20 | 21 | )} 22 |
    23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /apps/demo/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const ARRAY_SIZE = 20; 4 | const RESPONSE_TIME_IN_MS = 1000; 5 | 6 | export interface Item { 7 | key: number; 8 | value: string; 9 | } 10 | 11 | interface Response { 12 | hasNextPage: boolean; 13 | data: Item[]; 14 | } 15 | 16 | function loadItems(startCursor = 0): Promise { 17 | return new Promise((resolve) => { 18 | let newArray: Item[] = []; 19 | 20 | setTimeout(() => { 21 | for (let i = startCursor; i < startCursor + ARRAY_SIZE; i++) { 22 | const newItem = { 23 | key: i, 24 | value: `This is item ${i.toString()}`, 25 | }; 26 | newArray = [...newArray, newItem]; 27 | } 28 | 29 | resolve({ hasNextPage: true, data: newArray }); 30 | }, RESPONSE_TIME_IN_MS); 31 | }); 32 | } 33 | 34 | export function useLoadItems() { 35 | const [loading, setLoading] = useState(false); 36 | const [items, setItems] = useState([]); 37 | const [hasNextPage, setHasNextPage] = useState(true); 38 | const [error, setError] = useState(); 39 | 40 | async function loadMore() { 41 | setLoading(true); 42 | try { 43 | const { data, hasNextPage: newHasNextPage } = await loadItems( 44 | items.length, 45 | ); 46 | setItems((current) => [...current, ...data]); 47 | setHasNextPage(newHasNextPage); 48 | } catch (error_) { 49 | setError( 50 | error_ instanceof Error ? error_ : new Error('Something went wrong'), 51 | ); 52 | } finally { 53 | setLoading(false); 54 | } 55 | } 56 | 57 | return { loading, items, hasNextPage, error, loadMore }; 58 | } 59 | -------------------------------------------------------------------------------- /apps/demo/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /apps/demo/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | export default { 4 | content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'], 5 | plugins: [], 6 | } satisfies Config; 7 | -------------------------------------------------------------------------------- /apps/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/nextjs.json", 3 | "compilerOptions": { 4 | "plugins": [ 5 | { 6 | "name": "next" 7 | } 8 | ] 9 | }, 10 | "include": [ 11 | "next-env.d.ts", 12 | "**/*.ts", 13 | "**/*.tsx", 14 | "**/*.mjs", 15 | "**/*.js", 16 | ".next/types/**/*.ts" 17 | ], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | '*': 'prettier --write --ignore-unknown', 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-infinite-scroll-hook", 3 | "private": true, 4 | "license": "MIT", 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "build": "turbo build", 11 | "build:bundle": "turbo build --filter=react-infinite-scroll-hook", 12 | "codequality": "npm run format && npm run lint && npm run typecheck", 13 | "codequality:fix": "npm run format:fix && npm run lint:fix && npm run typecheck", 14 | "contributors:add": "all-contributors add", 15 | "contributors:generate": "all-contributors generate", 16 | "dev": "turbo dev", 17 | "format": "prettier --check --ignore-unknown .", 18 | "format:fix": "prettier --write --ignore-unknown .", 19 | "lint": "turbo lint", 20 | "lint:fix": "turbo lint:fix", 21 | "prepare": "husky && npm run build:bundle", 22 | "typecheck": "turbo typecheck", 23 | "updates": "npm-check-updates -i -ws --root" 24 | }, 25 | "devDependencies": { 26 | "all-contributors-cli": "^6.26.1", 27 | "husky": "^9.1.7", 28 | "lint-staged": "^15.5.1", 29 | "npm-check-updates": "^18.0.1", 30 | "prettier": "^3.5.3", 31 | "prettier-plugin-organize-imports": "^4.1.0", 32 | "prettier-plugin-tailwindcss": "^0.6.11", 33 | "turbo": "^2.5.2" 34 | }, 35 | "packageManager": "npm@11.3.0", 36 | "engines": { 37 | "node": ">=18" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/eslint-config/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/eslint-config/next.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('node:path'); 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | module.exports = { 5 | extends: [ 6 | require.resolve('@vercel/style-guide/eslint/browser'), 7 | require.resolve('@vercel/style-guide/eslint/node'), 8 | require.resolve('@vercel/style-guide/eslint/react'), 9 | require.resolve('@vercel/style-guide/eslint/next'), 10 | require.resolve('./typescript'), 11 | require.resolve('./unicorn'), 12 | require.resolve('./prettier'), 13 | 'turbo', 14 | ], 15 | plugins: ['only-warn'], 16 | settings: { 17 | 'import/resolver': { 18 | typescript: { 19 | project: resolve(process.cwd(), 'tsconfig.json'), 20 | }, 21 | }, 22 | }, 23 | ignorePatterns: [ 24 | // Ignore dotfiles 25 | '.*.js', 26 | 'node_modules/', 27 | ], 28 | overrides: [{ files: ['*.js?(x)', '*.ts?(x)'] }], 29 | rules: { 30 | // `curly` rule is not working even if it is in `@vercel/style-guide/eslint/browser` and '@vercel/style-guide/eslint/node'. 31 | // The reason is, it conflicts with `eslint-config-prettier` and gets overriden 32 | // when it comes after this config file in the `extends` field of the root config file. 33 | // So, we add it here to make it work. 34 | curly: ['warn', 'multi-line'], 35 | 'import/no-default-export': 'off', 36 | 'react/jsx-no-leaked-render': 'off', 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /packages/eslint-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/eslint-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "next.js", 7 | "prettier.js", 8 | "react-library.js", 9 | "typescript.js", 10 | "unicorn.js" 11 | ], 12 | "devDependencies": { 13 | "@typescript-eslint/eslint-plugin": "^7.17.0", 14 | "@typescript-eslint/parser": "^7.17.0", 15 | "@vercel/style-guide": "^6.0.0", 16 | "eslint-config-prettier": "^9.1.0", 17 | "eslint-config-turbo": "^2.0.14", 18 | "eslint-plugin-only-warn": "^1.1.0", 19 | "typescript": "^5.8.3" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/eslint-config/prettier.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | extends: ['prettier'], 4 | rules: { 5 | curly: ['warn', 'multi-line'], 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/eslint-config/react-library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('node:path'); 2 | 3 | /** @type {import("eslint").Linter.Config} */ 4 | module.exports = { 5 | extends: [ 6 | require.resolve('@vercel/style-guide/eslint/browser'), 7 | require.resolve('@vercel/style-guide/eslint/react'), 8 | require.resolve('./typescript'), 9 | require.resolve('./unicorn'), 10 | require.resolve('./prettier'), 11 | 'turbo', 12 | ], 13 | plugins: ['only-warn'], 14 | settings: { 15 | 'import/resolver': { 16 | typescript: { 17 | project: resolve(process.cwd(), 'tsconfig.json'), 18 | }, 19 | }, 20 | }, 21 | ignorePatterns: [ 22 | // Ignore dotfiles 23 | '.*.js', 24 | 'node_modules/', 25 | 'dist/', 26 | ], 27 | overrides: [ 28 | // Force ESLint to detect .tsx files 29 | { files: ['*.js?(x)', '*.ts?(x)'] }, 30 | ], 31 | rules: { 32 | 'import/no-default-export': 'off', 33 | 'react/jsx-no-leaked-render': 'off', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /packages/eslint-config/typescript.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | overrides: [ 4 | { 5 | files: ['*.ts?(x)'], 6 | extends: [require.resolve('@vercel/style-guide/eslint/typescript')], 7 | rules: { 8 | '@typescript-eslint/consistent-type-definitions': 'off', 9 | '@typescript-eslint/explicit-function-return-type': 'off', 10 | '@typescript-eslint/naming-convention': 'off', 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /packages/eslint-config/unicorn.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | extends: ['plugin:unicorn/recommended'], 4 | // https://github.com/sindresorhus/eslint-plugin-unicorn/tree/main?tab=readme-ov-file#rules 5 | rules: { 6 | 'unicorn/prevent-abbreviations': 'off', 7 | 'unicorn/no-null': 'off', 8 | 'unicorn/prefer-module': 'off', 9 | 'unicorn/explicit-length-check': 'off', 10 | 'unicorn/prefer-ternary': 'off', 11 | 'unicorn/no-array-for-each': 'off', 12 | 'unicorn/prefer-export-from': 'off', 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /packages/lint-staged-config/generic.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | '*': 'prettier --write --ignore-unknown', 3 | '*.{js,jsx,ts,tsx}': 'eslint --max-warnings 0 --fix', 4 | }; 5 | 6 | export default config; 7 | -------------------------------------------------------------------------------- /packages/lint-staged-config/next.mjs: -------------------------------------------------------------------------------- 1 | // https://nextjs.org/docs/app/building-your-application/configuring/eslint#lint-staged 2 | import path from 'node:path'; 3 | 4 | const buildEslintCommand = (filenames) => 5 | `next lint --max-warnings 0 --fix --file ${filenames 6 | .map((f) => 7 | path 8 | .relative(process.cwd(), f) 9 | // Removing `apps/` part to make `next lint` command work with `lint-staged`. 10 | .replace(/apps\/[a-z-]+\//, ''), 11 | ) 12 | .join(' --file ')}`; 13 | 14 | const config = { 15 | '*': 'prettier --write --ignore-unknown', 16 | '*.{js,jsx,ts,tsx}': [buildEslintCommand], 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /packages/lint-staged-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/lint-staged-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "generic.mjs", 7 | "next.mjs" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | module.exports = { 3 | root: true, 4 | extends: ['@repo/eslint-config/react-library.js'], 5 | parser: '@typescript-eslint/parser', 6 | parserOptions: { 7 | project: './tsconfig.json', 8 | tsconfigRootDir: __dirname, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 onderonur 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. 22 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/README.md: -------------------------------------------------------------------------------- 1 | # react-infinite-scroll-hook 2 | 3 | ![Build status](https://img.shields.io/github/actions/workflow/status/onderonur/react-infinite-scroll-hook/quality.yml) 4 | ![License](https://img.shields.io/npm/l/react-infinite-scroll-hook) 5 | ![Version](https://img.shields.io/npm/v/react-infinite-scroll-hook) 6 | 7 | 8 | 9 | [![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors-) 10 | 11 | 12 | 13 | This is a hook to create infinite scroll components! 14 | **Live demo is [here](https://onderonur.github.io/react-infinite-scroll-hook/).** 15 | 16 | Basically, we need to set a `sentry` component to trigger infinite loading. When `sentry` becomes visible on the screen or it comes near to be visible (based on our config of course), it triggers infinite loading (by calling `onLoadMore` callback) all with the help of `IntersectionObserver`. 17 | 18 | `sentry` should be some component which will **not** be unmounted as long as we want to keep the infinite scrolling observer **active**. For example, we can use a "loading" indicator as our sentry. The trick is, because that we want to keep the infinite scrolling observer active as long as there is a **next page**, we need to keep this "loading" component mounted even if we don't have a `loading` flag as `true`. This will also keep our layout more consistent and prevent flickering etc. 19 | 20 | We don't need to use a "loading" component as the `sentry` and we can keep them separate too. It can be anything like some empty `div` or last item of your list etc. We just need to place it based on our scrolling direction. To bottom if we want to trigger loading when we scroll to bottom, to top if we want to trigger it when we scroll to top like a chat message box etc. Same approach can be used with horizontal scrolling too. 21 | 22 | Please check below to find examples with different layouts and libraries. 23 | 24 | **Note:** This package uses `IntersectionObserver` under the hood. You might want to check the browser compatibility from **[here](https://caniuse.com/intersectionobserver)** and if you want to support older browsers, you might need to use a polyfill. 25 | 26 | Before **v4**, `useInfiniteScroll` hook would basically check the DOM with an interval and look at the distance between the bottom of your "infinite" component and the bottom of the window. This was a simple solution. But it had its difficulties. It was not so easy to change the layout of your "infinite" component (like creating a chat message box with inverted scrolling etc). It was a requirement to modify the package based on each different use case. 27 | 28 | And also, checking the DOM with an interval by using `setInterval` wasn't a sophisticated solution. It was enough, but it had it's limits. 29 | With **v4**, we migrated to use `IntersectionObserver` and created a much more flexible API to support different design. Basically, now we have a little bit more [inversion of control](https://kentcdodds.com/blog/inversion-of-control). 30 | 31 | If you want to use the older version which is using `setInterval`, you can find it **[here](https://github.com/onderonur/react-infinite-scroll-hook/tree/v3)**. 32 | 33 | ## Installation 34 | 35 | ```sh 36 | npm install react-infinite-scroll-hook 37 | ``` 38 | 39 | ## Simple Example 40 | 41 | ```tsx 42 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 43 | 44 | function WindowScroll() { 45 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 46 | 47 | const [infiniteRef] = useInfiniteScroll({ 48 | loading, 49 | hasNextPage, 50 | onLoadMore: loadMore, 51 | // When there is an error, we stop infinite loading. 52 | // It can be reactivated by setting "error" state as undefined. 53 | disabled: Boolean(error), 54 | // `rootMargin` is passed to `IntersectionObserver`. 55 | // We can use it to trigger 'onLoadMore' when the sentry comes near to become 56 | // visible, instead of becoming fully visible on the screen. 57 | rootMargin: '0px 0px 400px 0px', 58 | }); 59 | 60 | return ( 61 |
    62 | 63 | {items.map((item) => ( 64 | {item.value} 65 | ))} 66 | 67 | {hasNextPage && } 68 |
    69 | ); 70 | } 71 | ``` 72 | 73 | Or if we have a scrollable container and we want to use it as our "list container" instead of `document`, we just need to use `rootRef` like: 74 | 75 | ```tsx 76 | import useInfiniteScroll from 'react-infinite-scroll-hook'; 77 | 78 | function VerticalElementScrollPage() { 79 | const { loading, items, hasNextPage, error, loadMore } = useLoadItems(); 80 | 81 | const [infiniteRef, { rootRef }] = useInfiniteScroll({ 82 | loading, 83 | hasNextPage, 84 | onLoadMore: loadMore, 85 | // When there is an error, we stop infinite loading. 86 | // It can be reactivated by setting "error" state as undefined. 87 | disabled: Boolean(error), 88 | // `rootMargin` is passed to `IntersectionObserver`. 89 | // We can use it to trigger 'onLoadMore' when the sentry comes near to become 90 | // visible, instead of becoming fully visible on the screen. 91 | rootMargin: '0px 0px 400px 0px', 92 | }); 93 | 94 | return ( 95 | 96 | 97 | {items.map((item) => ( 98 | {item.value} 99 | ))} 100 | 101 | {hasNextPage && } 102 | 103 | ); 104 | } 105 | ``` 106 | 107 | ## Other Examples 108 | 109 | You can find different layout examples **[here](./apps/demo/src/app/)**. **[Live demo](https://onderonur.github.io/react-infinite-scroll-hook/)** contains all of these cases. 110 | 111 | Also, we have more realistic examples with [SWR](https://github.com/onderonur/next-moviez) and [TanStack Query](https://github.com/onderonur/next-rickql) too. 112 | 113 | ## Arguments 114 | 115 | | Name | Description | Type | Optional | Default Value | 116 | | ----------- | ------------------------------------------------------------------------------------------------ | ------------ | -------- | ------------- | 117 | | loading | Some sort of "is fetching" info of the request. | boolean | ❌ | | 118 | | hasNextPage | If the list has more items to load. | boolean | ❌ | | 119 | | onLoadMore | The callback function to execute when the 'onLoadMore' is triggered. | VoidFunction | ❌ | | 120 | | rootMargin | We pass this to 'IntersectionObserver'. We can use it to configure when to trigger 'onLoadMore'. | string | ✅ | | 121 | | disabled | Flag to stop infinite scrolling. Can be used in case of an error etc too. | boolean | ✅ | | 122 | | delayInMs | How long it should wait before triggering 'onLoadMore' (in milliseconds). | number | ✅ | 100 | 123 | 124 | ## Contributors ✨ 125 | 126 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 |

    Eugene Fidelin

    💻

    Evan Cater

    📖

    Romain

    💡
    138 | 139 | 140 | 141 | 142 | 143 | 144 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 145 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/lint-staged.config.mjs: -------------------------------------------------------------------------------- 1 | export { default } from '@repo/lint-staged-config/generic.mjs'; 2 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-infinite-scroll-hook", 3 | "version": "6.0.0", 4 | "description": "A simple hook to create infinite scroll components", 5 | "keywords": [ 6 | "react", 7 | "react-hooks", 8 | "infinite-scroll", 9 | "react-component" 10 | ], 11 | "homepage": "https://onderonur.github.io/react-infinite-scroll-hook", 12 | "bugs": { 13 | "url": "https://github.com/onderonur/react-infinite-scroll-hook/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/onderonur/react-infinite-scroll-hook.git" 18 | }, 19 | "license": "MIT", 20 | "author": "onderonur", 21 | "exports": { 22 | ".": { 23 | "import": { 24 | "types": "./dist/index.d.mts", 25 | "default": "./dist/index.mjs" 26 | }, 27 | "require": { 28 | "types": "./dist/index.d.ts", 29 | "default": "./dist/index.js" 30 | } 31 | } 32 | }, 33 | "main": "./dist/index.js", 34 | "module": "./dist/index.mjs", 35 | "types": "./dist/index.d.ts", 36 | "files": [ 37 | "dist" 38 | ], 39 | "scripts": { 40 | "attw": "attw --pack .", 41 | "build": "tsup", 42 | "dev": "tsup --watch", 43 | "lint": "eslint . --max-warnings 0", 44 | "lint:fix": "eslint . --fix --max-warnings 0", 45 | "prepublishOnly": "npm run build", 46 | "publint": "publint", 47 | "typecheck": "tsc" 48 | }, 49 | "dependencies": { 50 | "react-intersection-observer-hook": "^4.0.0" 51 | }, 52 | "devDependencies": { 53 | "@arethetypeswrong/cli": "^0.17.4", 54 | "@repo/eslint-config": "*", 55 | "@repo/lint-staged-config": "*", 56 | "@repo/typescript-config": "*", 57 | "@types/react": "^19.1.2", 58 | "eslint": "^8.57.0", 59 | "publint": "^0.3.12", 60 | "react": "^19.1.0", 61 | "tsup": "^8.4.0", 62 | "typescript": "^5.8.3" 63 | }, 64 | "peerDependencies": { 65 | "react": ">=19", 66 | "react-dom": ">=19" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default, 3 | type UseInfiniteScrollHookArgs, 4 | type UseInfiniteScrollHookRefCallback, 5 | type UseInfiniteScrollHookResult, 6 | type UseInfiniteScrollHookRootRefCallback, 7 | } from './use-infinite-scroll'; 8 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/src/use-infinite-scroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { 3 | useTrackVisibility, 4 | type IntersectionObserverHookArgs, 5 | type IntersectionObserverHookRefCallback as UseInfiniteScrollHookRefCallback, 6 | type IntersectionObserverHookRootRefCallback as UseInfiniteScrollHookRootRefCallback, 7 | } from 'react-intersection-observer-hook'; 8 | 9 | const DEFAULT_DELAY_IN_MS = 100; 10 | 11 | export { 12 | type UseInfiniteScrollHookRefCallback, 13 | type UseInfiniteScrollHookRootRefCallback, 14 | }; 15 | 16 | export type UseInfiniteScrollHookResult = [ 17 | UseInfiniteScrollHookRefCallback, 18 | { rootRef: UseInfiniteScrollHookRootRefCallback }, 19 | ]; 20 | 21 | export type UseInfiniteScrollHookArgs = Pick< 22 | IntersectionObserverHookArgs, 23 | // We pass this to 'IntersectionObserver'. We can use it to configure when to trigger 'onLoadMore'. 24 | 'rootMargin' 25 | > & { 26 | // Some sort of "is fetching" info of the request. 27 | loading: boolean; 28 | // If the list has more items to load. 29 | hasNextPage: boolean; 30 | // The callback function to execute when the 'onLoadMore' is triggered. 31 | onLoadMore: () => unknown; 32 | // Flag to stop infinite scrolling. Can be used in case of an error etc too. 33 | disabled?: boolean; 34 | // How long it should wait before triggering 'onLoadMore'. 35 | delayInMs?: number; 36 | }; 37 | 38 | function useInfiniteScroll({ 39 | loading, 40 | hasNextPage, 41 | onLoadMore, 42 | rootMargin, 43 | disabled, 44 | delayInMs = DEFAULT_DELAY_IN_MS, 45 | }: UseInfiniteScrollHookArgs): UseInfiniteScrollHookResult { 46 | const savedCallbackRef = useRef(onLoadMore); 47 | const [ref, { rootRef, isVisible }] = useTrackVisibility({ 48 | rootMargin, 49 | }); 50 | 51 | useEffect(() => { 52 | savedCallbackRef.current = onLoadMore; 53 | }, [onLoadMore]); 54 | 55 | const shouldLoadMore = !disabled && !loading && isVisible && hasNextPage; 56 | 57 | useEffect(() => { 58 | if (shouldLoadMore) { 59 | // When we trigger 'onLoadMore' and new items are added to the list, 60 | // right before they become rendered on the screen, 'loading' becomes false 61 | // and 'isVisible' can be true for a brief time, based on the scroll position. 62 | // So, it triggers 'onLoadMore' just after the first one is finished. 63 | // We use a small delay here to prevent this kind of situations. 64 | // It can be configured by hook args. 65 | const timer = setTimeout(() => { 66 | savedCallbackRef.current(); 67 | }, delayInMs); 68 | return () => { 69 | clearTimeout(timer); 70 | }; 71 | } 72 | }, [shouldLoadMore, delayInMs]); 73 | 74 | return [ref, { rootRef }]; 75 | } 76 | 77 | export default useInfiniteScroll; 78 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/react-library.json", 3 | "include": ["**/*.ts", "**/*.tsx", "**/*.mjs"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/react-infinite-scroll-hook/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig(() => ({ 4 | entry: ['src/index.ts'], 5 | outDir: 'dist', 6 | format: ['cjs', 'esm'], 7 | // Clean output directory before each build 8 | clean: true, 9 | // Generate dts files 10 | dts: true, 11 | })); 12 | -------------------------------------------------------------------------------- /packages/typescript-config/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "esModuleInterop": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "lib": ["es2022", "DOM", "DOM.Iterable"], 11 | "module": "NodeNext", 12 | "moduleDetection": "force", 13 | "moduleResolution": "NodeNext", 14 | "noUncheckedIndexedAccess": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "target": "ES2022", 19 | // We use TypeScript only for linting/typechecking purposes. 20 | // So, we don't need it to emit anything. 21 | "noEmit": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/typescript-config/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "allowJs": true, 10 | "jsx": "preserve" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/typescript-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/typescript-config", 3 | "version": "0.0.0", 4 | "private": true, 5 | "publishConfig": { 6 | "access": "public" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/typescript-config/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | import styleguide from '@vercel/style-guide/prettier'; 2 | 3 | /** 4 | * @see https://prettier.io/docs/en/configuration.html 5 | * @type {import("prettier").Config} 6 | */ 7 | const config = { 8 | ...styleguide, 9 | // To skip destructive code actions of `prettier-plugin-organize-imports`, 10 | // removing unused imports: 11 | // https://www.npmjs.com/package/prettier-plugin-organize-imports#skip-destructive-code-actions 12 | organizeImportsSkipDestructiveCodeActions: true, 13 | plugins: [ 14 | ...styleguide.plugins, 15 | 'prettier-plugin-organize-imports', 16 | // Should be the last one. 17 | // https://github.com/tailwindlabs/prettier-plugin-tailwindcss?tab=readme-ov-file#compatibility-with-other-prettier-plugins 18 | 'prettier-plugin-tailwindcss', 19 | ], 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json" 3 | } 4 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 7 | "outputs": [".next/**", "!.next/cache/**", "dist/**"] 8 | }, 9 | "dev": { 10 | "cache": false, 11 | "persistent": true 12 | }, 13 | "lint": { 14 | "dependsOn": ["^build", "^lint"] 15 | }, 16 | "lint:fix": { 17 | "dependsOn": ["^build", "^lint:fix"] 18 | }, 19 | "typecheck": { 20 | "dependsOn": ["^build"] 21 | } 22 | } 23 | } 24 | --------------------------------------------------------------------------------