├── .all-contributorsrc ├── .babelrc ├── .changeset ├── README.md └── config.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .huskyrc.js ├── .npmrc ├── .prettierignore ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── spin-delay.gif ├── logo.svg ├── package-lock.json ├── package.json ├── src ├── index.test.js ├── index.ts └── tsconfig.json └── types └── index.d.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "spin-delay", 3 | "projectOwner": "smeijer", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "smeijer", 15 | "name": "Stephan Meijer", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/1196524?v=4", 17 | "profile": "https://github.com/smeijer", 18 | "contributions": [ 19 | "ideas", 20 | "code", 21 | "infra", 22 | "maintenance", 23 | "test" 24 | ] 25 | }, 26 | { 27 | "login": "Aprillion", 28 | "name": "Peter Hozák", 29 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4", 30 | "profile": "http://peter.hozak.info/", 31 | "contributions": [ 32 | "ideas", 33 | "test" 34 | ] 35 | }, 36 | { 37 | "login": "erichosick", 38 | "name": "Eric Hosick", 39 | "avatar_url": "https://avatars.githubusercontent.com/u/295228?v=4", 40 | "profile": "http://www.erichosick.com/", 41 | "contributions": [ 42 | "doc" 43 | ] 44 | }, 45 | { 46 | "login": "supachaidev", 47 | "name": "Supachai Dev", 48 | "avatar_url": "https://avatars.githubusercontent.com/u/88824768?v=4", 49 | "profile": "https://github.com/supachaidev", 50 | "contributions": [ 51 | "code" 52 | ] 53 | }, 54 | { 55 | "login": "kentcdodds", 56 | "name": "Kent C. Dodds", 57 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=4", 58 | "profile": "https://kentcdodds.com/", 59 | "contributions": [ 60 | "code" 61 | ] 62 | }, 63 | { 64 | "login": "chucamphong", 65 | "name": "Phong Chu", 66 | "avatar_url": "https://avatars.githubusercontent.com/u/58473133?v=4", 67 | "profile": "https://github.com/chucamphong", 68 | "contributions": [ 69 | "code" 70 | ] 71 | }, 72 | { 73 | "login": "joeporpeglia", 74 | "name": "Joe Porpeglia", 75 | "avatar_url": "https://avatars.githubusercontent.com/u/1399969?v=4", 76 | "profile": "https://github.com/joeporpeglia", 77 | "contributions": [ 78 | "code" 79 | ] 80 | } 81 | ], 82 | "contributorsPerLine": 7, 83 | "commitType": "docs" 84 | } 85 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "smeijer/spin-delay" } 6 | ], 7 | "commit": false, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": [] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | - next 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | token: ${{ secrets.MY_GITHUB_TOKEN }} 18 | - uses: bahmutov/npm-install@v1 19 | 20 | - name: Create Release Pull Request or Publish to npm 21 | uses: changesets/action@v1 22 | with: 23 | publish: npm run changeset:release 24 | commit: 'chore: version packages' 25 | title: 'next release' 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} 28 | NPM_TOKEN: ${{ secrets.MY_NPM_TOKEN }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'pre-commit': 'pretty-quick --staged', 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # spin-delay 2 | 3 | ## 2.0.1 4 | 5 | ### Patch Changes 6 | 7 | - [`f380792`](https://github.com/smeijer/spin-delay/commit/f380792a23c4331c4e389ebeeca03945a49c4848) Thanks [@smeijer](https://github.com/smeijer)! - Fixes the initial delay used when `options.ssr` is `false`. 8 | 9 | ## 2.0.0 10 | 11 | ### Major Changes 12 | 13 | - [#8](https://github.com/smeijer/spin-delay/pull/8) [`1b81585`](https://github.com/smeijer/spin-delay/commit/1b815854e454e2d10357f2dd586370ef9de44b4d) Thanks [@smeijer](https://github.com/smeijer)! - We now support spinner initialization from the server (SSR). When the `loading` prop is `true` due to server-side rendering, the spinner will be shown immediately. You can opt-out of this behavior by setting the `ssr` option to `false`. 14 | 15 | ```tsx 16 | import { useSpinDelay } from 'spin-delay'; 17 | 18 | const spin = useSpinDelay(loading, { 19 | ssr: false, // defaults to true 20 | }); 21 | ``` 22 | 23 | - [#6](https://github.com/smeijer/spin-delay/pull/6) [`3d4f4d5`](https://github.com/smeijer/spin-delay/commit/3d4f4d51db5c3e0b9a301ff5ada5e9efbe5fd35a) Thanks [@smeijer](https://github.com/smeijer)! - We've to removed the default export. Please update your code to use the named 24 | export instead. This is a breaking change, but it's a minor one. Chances are 25 | that you're already using the named export, and you don't have to do anything. 26 | 27 | ```diff 28 | - import useSpinDelay from 'spin-delay'; 29 | + import { useSpinDelay } from 'spin-delay'; 30 | ``` 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stephan Meijer 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 | # spin-delay 2 | 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors-) 5 | 6 | 7 | **Smart Spinner Helper for React** 8 | 9 | ![demo animation of spin-delay](./docs/spin-delay.gif) 10 | 11 | There are a few annoyances when working with spinners. Network request can be so 12 | fast that rendering a spinner does more harm than good. Why render a spinner 13 | when loading the data only takes like 50ms? 14 | 15 | This can be fixed by adding a delay. Only show the spinner when the request takes 16 | longer than 200ms for example. And what happens when the request takes 210ms? Right, 17 | we see a spinner for 10ms. This flicker can be annoying. 18 | 19 | `spin-delay` solves these issues by wrapping your booleans, and only returning 20 | true after the `delay`, and for a minimum time of `minDuration`. This way 21 | you're sure that you don't show unnecessary or very short living spinners. 22 | 23 | ## Demo 24 | 25 | Sandbox -> https://codesandbox.io/s/spin-delay-jlp2c 26 | 27 | ## Installation 28 | 29 | With npm: 30 | 31 | ```sh 32 | npm install --save spin-delay 33 | ``` 34 | 35 | With yarn: 36 | 37 | ```sh 38 | yarn add spin-delay 39 | ``` 40 | 41 | ## API 42 | 43 | The examples below use the following data object: 44 | 45 | ```jsx 46 | import { useSpinDelay } from 'spin-delay'; 47 | 48 | function MyComponent() { 49 | const [{ loading }] = useFetch('http://example.com'); 50 | 51 | // options are optional, and default to these values 52 | const showSpinner = useSpinDelay(loading, { delay: 500, minDuration: 200 }); 53 | 54 | if (showSpinner) { 55 | return ; 56 | } 57 | 58 | // ... 59 | } 60 | ``` 61 | 62 | ## Contributors ✨ 63 | 64 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
Stephan Meijer
Stephan Meijer

🤔 💻 🚇 🚧 ⚠️
Peter Hozák
Peter Hozák

🤔 ⚠️
Eric Hosick
Eric Hosick

📖
Supachai Dev
Supachai Dev

💻
Kent C. Dodds
Kent C. Dodds

💻
Phong Chu
Phong Chu

💻
Joe Porpeglia
Joe Porpeglia

💻
82 | 83 | 84 | 85 | 86 | 87 | 88 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 89 | -------------------------------------------------------------------------------- /docs/spin-delay.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/spin-delay/f76e32e5f5090111eff4ee7e70b8611b60bf5a1c/docs/spin-delay.gif -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spin-delay", 3 | "version": "2.0.1", 4 | "description": "Smart spinner helper for React, to manage the duration of loading states.", 5 | "source": "src/index.ts", 6 | "main": "dist/index.js", 7 | "types": "types/index.d.ts", 8 | "license": "MIT", 9 | "author": "Stephan Meijer ", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/smeijer/spin-delay.git" 13 | }, 14 | "scripts": { 15 | "test": "jest", 16 | "build": "rimraf ./dist && microbundle -i src/index.ts -o dist/index.js --no-pkg-main -f umd --target node", 17 | "watch": "rimraf ./dist && microbundle -i src/index.ts -o dist/index.js --no-pkg-main -f umd --sourcemap true --compress false --target node --watch --raw", 18 | "prettier": "prettier . --write", 19 | "changeset": "changeset", 20 | "changeset:version": "changeset version", 21 | "changeset:release": "npm run build && prettier --write 'CHANGELOG.md' '.changeset/*.json' && changeset publish" 22 | }, 23 | "files": [ 24 | "docs", 25 | "dist", 26 | "types" 27 | ], 28 | "keywords": [ 29 | "react", 30 | "loader", 31 | "suspense", 32 | "delay" 33 | ], 34 | "devDependencies": { 35 | "@babel/preset-env": "^7.12.7", 36 | "@babel/preset-react": "^7.12.7", 37 | "@babel/preset-typescript": "^7.12.7", 38 | "@changesets/changelog-github": "^0.5.0", 39 | "@changesets/cli": "^2.27.1", 40 | "@testing-library/react": "^11.2.2", 41 | "@types/react": "^17.0.0", 42 | "husky": "^4.3.0", 43 | "jest": "^26.4.2", 44 | "microbundle": "^0.12.4", 45 | "prettier": "^2.1.2", 46 | "pretty-quick": "^3.0.2", 47 | "react": "^17.0.1", 48 | "react-dom": "^17.0.1", 49 | "regenerator-runtime": "^0.13.7", 50 | "rimraf": "^3.0.2" 51 | }, 52 | "peerDependencies": { 53 | "react": ">=17.0.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime'; 2 | import React, { useState, useEffect } from 'react'; 3 | import { render, screen, act } from '@testing-library/react'; 4 | import { useSpinDelay } from './index'; 5 | 6 | it('does not show spinner when faster than delay', () => { 7 | setup({ networkTime: 100, delay: 200 }); 8 | assertLoadingAndSpinnerAtTime(true, false, 0); 9 | assertLoadingAndSpinnerAtTime(false, false, 100); 10 | }); 11 | 12 | it('shows spinner when slower than delay', () => { 13 | setup({ networkTime: 300, delay: 200 }); 14 | assertLoadingAndSpinnerAtTime(true, false, 0); 15 | 16 | assertLoadingAndSpinnerAtTime(true, false, 199); 17 | assertLoadingAndSpinnerAtTime(true, true, 200); 18 | 19 | assertLoadingAndSpinnerAtTime(true, true, 299); 20 | assertLoadingAndSpinnerAtTime(false, false, 300); 21 | }); 22 | 23 | it('shows spinner for minDuration', () => { 24 | setup({ networkTime: 300, delay: 200, minDuration: 200 }); 25 | assertLoadingAndSpinnerAtTime(true, false, 0); 26 | 27 | assertLoadingAndSpinnerAtTime(true, false, 199); 28 | assertLoadingAndSpinnerAtTime(true, true, 200); 29 | 30 | assertLoadingAndSpinnerAtTime(true, true, 299); 31 | assertLoadingAndSpinnerAtTime(false, true, 300); 32 | 33 | assertLoadingAndSpinnerAtTime(false, true, 399); 34 | assertLoadingAndSpinnerAtTime(false, false, 401); 35 | }); 36 | 37 | // utility functions: 38 | 39 | function setup({ networkTime, delay, minDuration }) { 40 | function TestComponent({ networkTime, delay, minDuration }) { 41 | const [loading, setLoading] = useState(true); 42 | const showSpinner = useSpinDelay(loading, { delay, minDuration }); 43 | 44 | useEffect(() => { 45 | setTimeout(() => setLoading(false), networkTime); 46 | }, [networkTime]); 47 | 48 | return JSON.stringify({ loading, showSpinner }); 49 | } 50 | 51 | render( 52 | , 57 | ); 58 | } 59 | 60 | let currentTime; 61 | function advanceTimersTo(time) { 62 | act(() => jest.advanceTimersByTime(time - currentTime)); 63 | currentTime = time; 64 | } 65 | 66 | function assertLoadingAndSpinnerAtTime(loading, showSpinner, time) { 67 | advanceTimersTo(time); 68 | screen.getByText(JSON.stringify({ loading, showSpinner })); 69 | } 70 | 71 | beforeEach(() => { 72 | jest.useFakeTimers(); 73 | currentTime = 0; 74 | }); 75 | 76 | afterEach(() => { 77 | jest.runOnlyPendingTimers(); 78 | jest.useRealTimers(); 79 | }); 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | interface SpinDelayOptions { 4 | /** 5 | * The delay in milliseconds before the spinner is displayed. 6 | * @default 500 7 | */ 8 | delay?: number; 9 | /** 10 | * The minimum duration in milliseconds the spinner is displayed. 11 | * @default 200 12 | */ 13 | minDuration?: number; 14 | /** 15 | * Whether to enable the spinner on the server side. If true, `delay` will be 16 | * ignored, and the spinner will be shown immediately if `loading` is true. 17 | * @default true 18 | */ 19 | ssr?: boolean; 20 | } 21 | 22 | type State = 'IDLE' | 'DELAY' | 'DISPLAY' | 'EXPIRE'; 23 | 24 | export const defaultOptions = { 25 | delay: 500, 26 | minDuration: 200, 27 | ssr: true, 28 | }; 29 | 30 | function useIsSSR() { 31 | const [isSSR, setIsSSR] = useState(true); 32 | 33 | useEffect(() => { 34 | setIsSSR(false); 35 | }, []); 36 | 37 | return isSSR; 38 | } 39 | 40 | export function useSpinDelay( 41 | loading: boolean, 42 | options?: SpinDelayOptions, 43 | ): boolean { 44 | options = Object.assign({}, defaultOptions, options); 45 | 46 | const isSSR = useIsSSR() && options.ssr; 47 | const initialState = isSSR && loading ? 'DISPLAY' : 'IDLE'; 48 | const [state, setState] = useState(initialState); 49 | const timeout = useRef(null); 50 | 51 | useEffect(() => { 52 | if (loading && (state === 'IDLE' || isSSR)) { 53 | clearTimeout(timeout.current); 54 | 55 | const delay = isSSR ? 0 : options.delay; 56 | timeout.current = setTimeout(() => { 57 | if (!loading) { 58 | return setState('IDLE'); 59 | } 60 | 61 | timeout.current = setTimeout(() => { 62 | setState('EXPIRE'); 63 | }, options.minDuration); 64 | 65 | setState('DISPLAY'); 66 | }, delay); 67 | 68 | if (!isSSR) { 69 | setState('DELAY'); 70 | } 71 | } 72 | 73 | if (!loading && state !== 'DISPLAY') { 74 | clearTimeout(timeout.current); 75 | setState('IDLE'); 76 | } 77 | }, [loading, state, options.delay, options.minDuration, isSSR]); 78 | 79 | useEffect(() => { 80 | return () => clearTimeout(timeout.current); 81 | }, []); 82 | 83 | return state === 'DISPLAY' || state === 'EXPIRE'; 84 | } 85 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "sourceMap": true, 6 | "lib": ["es2015", "dom"] 7 | }, 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | interface SpinDelayOptions { 2 | /** 3 | * The delay in milliseconds before the spinner is displayed. 4 | * @default 500 5 | */ 6 | delay?: number; 7 | /** 8 | * The minimum duration in milliseconds the spinner is displayed. 9 | * @default 200 10 | */ 11 | minDuration?: number; 12 | /** 13 | * Whether to enable the spinner on the server side. If true, `delay` will be 14 | * ignored, and the spinner will be shown immediately if `loading` is true. 15 | * @default true 16 | */ 17 | ssr?: boolean; 18 | } 19 | export declare const defaultOptions: { 20 | delay: number; 21 | minDuration: number; 22 | ssr: true; 23 | }; 24 | export declare function useSpinDelay( 25 | loading: boolean, 26 | options?: SpinDelayOptions, 27 | ): boolean; 28 | --------------------------------------------------------------------------------