├── .babelrc ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── build ├── asset-manifest.json ├── favicon.ico ├── index.html ├── manifest.json ├── service-worker.js └── static │ └── js │ ├── runtime~main.fdfcfda2.js │ └── runtime~main.fdfcfda2.js.map ├── dist └── index.js ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── rollup.config.js ├── src ├── App.jsx ├── InfiniteList.jsx ├── InfiniteList.test.jsx ├── index.jsx └── mock_data.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:react/recommended"], 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parserOptions": { 13 | "ecmaFeatures": { 14 | "jsx": true 15 | }, 16 | "ecmaVersion": 2018, 17 | "sourceType": "module" 18 | }, 19 | "plugins": ["react", "react-hooks"], 20 | "rules": { 21 | "react-hooks/rules-of-hooks": "error", 22 | "react-hooks/exhaustive-deps": "warn" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.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 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | public 3 | build 4 | .babelrc 5 | rollup.config.js 6 | .eslintrc.json 7 | yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 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 | # react-infinite-list 2 | 3 | A handy React component to render infinite, onScroll fetched, data lists. 4 | 5 | Demo: https://react-infinite-list.netlify.com 6 | 7 | ![GitHub](https://img.shields.io/github/license/mdubourg001/react-infinite-list.svg) 8 | ![npm](https://img.shields.io/npm/v/@damnhotuser/react-infinite-list.svg) 9 | 10 | --- 11 | 12 | ### Install 13 | 14 | ```sh 15 | $ npm install @damnhotuser/react-infinite-list 16 | ``` 17 | 18 | --- 19 | 20 | ### Usage 21 | 22 | `react-infinite-list` provides a single component, `InfiniteList`. 23 | 24 | ```jsx 25 | import React, { useState } from "react"; 26 | 27 | import InfiniteList from "@damnhotuser/react-infinite-list"; 28 | import mock_fetch from "./mock_data"; // mocking an API service 29 | 30 | const App = props => { 31 | const [rows, setRows] = useState([]); 32 | 33 | const fetchData = (offset, limit) => 34 | mock_fetch(offset, limit).then(data => { 35 | setRows([...rows, ...data]); 36 | }); 37 | 38 | return ( 39 | /* 40 | * InfiniteList takes three needed arguments: 41 | * `rows` are data to display in the list 42 | * `fetchData` is called on rendering and when needed, on scroll 43 | * `limit` is the number of rows to fetch on each `fetchData` call 44 | * 45 | * InfiniteList's `children` must be a function, and has the row to render passed as an argument 46 | * 47 | * current `index` and `ref` will also be passed as arguments of your `children` function, it is IMPORTANT to pass ref to the rendered list element for the onScroll fetch to work 48 | */ 49 | 50 | {(row, index, ref) => ( 51 |
52 | {row.name} 53 |
54 | )} 55 |
56 | ); 57 | }; 58 | ``` 59 | 60 |
61 | 62 | **⚠️ InfiniteList's `children` must be a function. The current row to render will be passed as an argument.** 63 | 64 | **⚠️ current `index` and `ref` will also be passed as arguments of your `children` function, it is IMPORTANT to pass ref to the rendered list element for the onScroll fetch to work** 65 | 66 |
67 | 68 | `InfiniteList` takes also 3 optionnal arguments: 69 | 70 | - `containerClasses` are classes that will be passed as an argument to the `div` holding your list. 71 | - `containerStyle` are style rules that will be passed as an argument to the `div` holding your list. 72 | - `fetchThreshold` (number) is the number of element before the end of the displayed list to wait before calling `fetchData` again. i.e. if n elements are displayed on the list, and `fetchThreshold` is set to 3,`fetchData` will be called when the n-3th element of the list is scrolled into view. **The default value of `fetchthreshold` is 5**. 73 | -------------------------------------------------------------------------------- /build/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "main.js": "/static/js/main.869cced1.chunk.js", 3 | "main.js.map": "/static/js/main.869cced1.chunk.js.map", 4 | "runtime~main.js": "/static/js/runtime~main.fdfcfda2.js", 5 | "runtime~main.js.map": "/static/js/runtime~main.fdfcfda2.js.map", 6 | "static/js/2.48d1e2ad.chunk.js": "/static/js/2.48d1e2ad.chunk.js", 7 | "static/js/2.48d1e2ad.chunk.js.map": "/static/js/2.48d1e2ad.chunk.js.map", 8 | "index.html": "/index.html", 9 | "precache-manifest.14377052761068e1c0ccbd5088347daa.js": "/precache-manifest.14377052761068e1c0ccbd5088347daa.js", 10 | "service-worker.js": "/service-worker.js" 11 | } -------------------------------------------------------------------------------- /build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdubourg001/react-infinite-list/a8328a13a84c62dcfe3f35649cc8904a5307bc59/build/favicon.ico -------------------------------------------------------------------------------- /build/index.html: -------------------------------------------------------------------------------- 1 | React App
-------------------------------------------------------------------------------- /build/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /build/service-worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Welcome to your Workbox-powered service worker! 3 | * 4 | * You'll need to register this file in your web app and you should 5 | * disable HTTP caching for this file too. 6 | * See https://goo.gl/nhQhGp 7 | * 8 | * The rest of the code is auto-generated. Please don't update this file 9 | * directly; instead, make changes to your Workbox build configuration 10 | * and re-run your build process. 11 | * See https://goo.gl/2aRDsh 12 | */ 13 | 14 | importScripts("https://storage.googleapis.com/workbox-cdn/releases/3.6.3/workbox-sw.js"); 15 | 16 | importScripts( 17 | "/precache-manifest.14377052761068e1c0ccbd5088347daa.js" 18 | ); 19 | 20 | workbox.clientsClaim(); 21 | 22 | /** 23 | * The workboxSW.precacheAndRoute() method efficiently caches and responds to 24 | * requests for URLs in the manifest. 25 | * See https://goo.gl/S9QRab 26 | */ 27 | self.__precacheManifest = [].concat(self.__precacheManifest || []); 28 | workbox.precaching.suppressWarnings(); 29 | workbox.precaching.precacheAndRoute(self.__precacheManifest, {}); 30 | 31 | workbox.routing.registerNavigationRoute("/index.html", { 32 | 33 | blacklist: [/^\/_/,/\/[^\/]+\.[^\/]+$/], 34 | }); 35 | -------------------------------------------------------------------------------- /build/static/js/runtime~main.fdfcfda2.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c=r.top&&i.top0.2%", 22 | "not dead", 23 | "not ie <= 11", 24 | "not op_mini all" 25 | ], 26 | "devDependencies": { 27 | "@babel/core": "^7.3.4", 28 | "@babel/preset-env": "^7.3.4", 29 | "@babel/preset-react": "^7.0.0", 30 | "babel-jest": "23.6.0", 31 | "eslint": "5.12.0", 32 | "eslint-plugin-react": "^7.12.4", 33 | "eslint-plugin-react-hooks": "^1.2.0", 34 | "rollup": "^1.4.1", 35 | "rollup-plugin-babel": "^4.3.2", 36 | "rollup-plugin-terser": "^4.0.4", 37 | "react-test-renderer": "16.8.3", 38 | "react-testing-library": "6.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdubourg001/react-infinite-list/a8328a13a84c62dcfe3f35649cc8904a5307bc59/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | 16 | 25 | React App 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import terser from "rollup-plugin-terser"; 2 | import babel from "rollup-plugin-babel"; 3 | 4 | const config = { 5 | input: "src/InfiniteList.jsx", 6 | external: ["react"], 7 | output: { 8 | format: "umd", 9 | name: "react-infinite-list", 10 | globals: { 11 | react: "React" 12 | } 13 | }, 14 | plugins: [ 15 | babel({ 16 | exclude: "node_modules/**" 17 | }), 18 | terser.terser() 19 | ] 20 | }; 21 | export default config; 22 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import InfiniteList from "./InfiniteList"; 3 | import mock_fetch from "./mock_data"; 4 | 5 | const App = () => { 6 | const [rows, setRows] = useState([]); 7 | const limit = 10; 8 | 9 | const fetchData = offset => 10 | mock_fetch(offset, limit).then(data => { 11 | setRows([...rows, ...data]); 12 | }); 13 | 14 | return ( 15 | 16 | {(row, index, ref) => ( 17 |
18 | {row.name} 19 |
20 | )} 21 |
22 | ); 23 | }; 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /src/InfiniteList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState } from "react"; 2 | 3 | const InfiniteList = ({ 4 | rows, 5 | fetchData, 6 | limit, 7 | containerClasses, 8 | containerStyle, 9 | fetchThreshold, 10 | children 11 | }) => { 12 | const [offset, setOffset] = useState(0); 13 | const viewRef = useRef(null); 14 | const [refs, setRefs] = useState(new Map()); 15 | const fetchTh = fetchThreshold ? fetchThreshold : 5; 16 | 17 | const defaultStyle = { 18 | width: "200px", 19 | maxHeight: "100px", 20 | overflowY: "scroll", 21 | marginTop: "100px", 22 | marginLeft: "auto", 23 | marginRight: "auto" 24 | }; 25 | 26 | const isScrolledIntoView = (elem, view) => { 27 | const viewRect = view.getBoundingClientRect(); 28 | const elemRect = elem.getBoundingClientRect(); 29 | 30 | return ( 31 | elemRect.top + elemRect.height >= viewRect.top && 32 | elemRect.top < viewRect.top + viewRect.height 33 | ); 34 | }; 35 | 36 | useEffect(() => { 37 | if (rows.length === 0) fetchData(offset); 38 | setOffset(rows.length); 39 | }, [rows]); 40 | 41 | return ( 42 |
{ 46 | for ( 47 | let i = refs.size - fetchTh < 0 ? 0 : refs.size - fetchTh; 48 | i < refs.size; 49 | i++ 50 | ) { 51 | if (isScrolledIntoView(refs.get(i), viewRef.current)) { 52 | fetchData(offset, limit); 53 | break; 54 | } 55 | } 56 | }} 57 | className={containerClasses} 58 | style={containerStyle ? containerStyle : defaultStyle} 59 | > 60 | {rows.map((row, index) => 61 | children(row, index, c => setRefs(refs.set(index, c))) 62 | )} 63 |
64 | ); 65 | }; 66 | 67 | export default InfiniteList; 68 | -------------------------------------------------------------------------------- /src/InfiniteList.test.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, cleanup, fireEvent } from "react-testing-library"; 3 | 4 | import InfiniteList from "./InfiniteList"; 5 | import mock_fetch from "./mock_data"; 6 | 7 | let rows = []; 8 | const limit = 10; 9 | 10 | afterEach(cleanup); 11 | 12 | it("should fetch on first render", () => { 13 | const fetchData = jest.fn(offset => { 14 | mock_fetch(offset, limit).then(data => { 15 | rows = [...rows, ...data]; 16 | expect(rows.length).toBe(limit); 17 | }); 18 | }); 19 | 20 | render( 21 | 22 | {(row, index, ref) => ( 23 |
24 | {row.name} 25 |
26 | )} 27 |
28 | ); 29 | 30 | // should have been called since row was empty on render 31 | expect(fetchData).toHaveBeenCalledTimes(1); 32 | }); 33 | 34 | /* 35 | it("should fetch again onScroll", () => { 36 | rows = []; 37 | for (let i = 0; i < 7; i++) { 38 | rows.push({ name: `I'm row number ${i}` }); 39 | } 40 | 41 | const fetchData = jest.fn(); 42 | 43 | const { container } = render( 44 | 45 | {(row, index, ref) => ( 46 |
47 | {row.name} 48 |
49 | )} 50 |
51 | ); 52 | 53 | // should not have been called yet since rows isn't empty 54 | expect(fetchData).toHaveBeenCalledTimes(0); 55 | 56 | const infiniteListWrapper = container.querySelector("#infinite-list-wrapper"); 57 | console.log(infiniteListWrapper); 58 | 59 | fireEvent.scroll(infiniteListWrapper); 60 | }); 61 | */ 62 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | ReactDOM.render(, document.getElementById("root")); -------------------------------------------------------------------------------- /src/mock_data.js: -------------------------------------------------------------------------------- 1 | const MOCK_DATA = (offset, limit) => { 2 | const data = []; 3 | for (let i = 0; i < limit; i++) { 4 | data.push({ name: `Offset is ${offset + i}, limit is ${limit}` }); 5 | } 6 | return data; 7 | }; 8 | 9 | export const mock_fetch = (offset, limit) => { 10 | return Promise.resolve(MOCK_DATA(offset, limit)); 11 | }; 12 | 13 | export default mock_fetch; 14 | --------------------------------------------------------------------------------