├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── pub-npm.yml ├── .gitignore ├── LICENSE ├── example ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.tsx │ ├── components │ │ ├── ImageDemo.tsx │ │ └── RequestDemo.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── tests │ │ ├── cancelable.axios.test.js │ │ ├── cancelable.fetch.test.js │ │ ├── types.test.js │ │ ├── utils.js │ │ └── utils.test.js ├── tsconfig.json └── yarn.lock ├── package.json ├── readme.md ├── src ├── index.tsx ├── types.ts ├── useCancelableImg.ts ├── useCancelableReq.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for GitHub Actions 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | 14 | # Maintain dependencies for npm 15 | - package-ecosystem: "npm" 16 | directory: "/" 17 | schedule: 18 | interval: "daily" 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to GH Pages 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [16.x] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Install Packages 19 | run: | 20 | yarn 21 | (cd example && yarn) 22 | - name: Build & Deploy 23 | run: | 24 | git config --global user.email "aguretsvlad@gmail.com" 25 | git config --global user.name "vladagurets" 26 | git remote set-url origin https://${{ secrets.MY_DEPLOY_KEY }}@github.com/vladagurets/react-cancelable.git 27 | yarn build 28 | cd example 29 | yarn build 30 | yarn deploy 31 | -------------------------------------------------------------------------------- /.github/workflows/pub-npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish build to NPM 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | # Setup .npmrc file to publish to npm 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version: '16.x' 14 | registry-url: 'https://registry.npmjs.org' 15 | - run: | 16 | yarn 17 | (cd example && yarn) 18 | yarn pub 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /example/node_modules 4 | /example/build 5 | 6 | lib 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vladyslav Ohirenko 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 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cancelable-example", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Make cancelable requests with react-hooks", 6 | "author": "Vladyslav Ohirenko 0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladagurets/react-cancelable/e7e830d074e48274b630fd61d20b8966cc82da78/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | react-cancelable 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 50 | 51 | 52 | 53 |
54 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /example/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladagurets/react-cancelable/e7e830d074e48274b630fd61d20b8966cc82da78/example/public/logo192.png -------------------------------------------------------------------------------- /example/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vladagurets/react-cancelable/e7e830d074e48274b630fd61d20b8966cc82da78/example/public/logo512.png -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-cancelable", 3 | "name": "react-cancelable. Make cancelable requests with react-hooks", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /example/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /example/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | 40 | .code { 41 | background-color: lightblue; 42 | padding: 5px 10px; 43 | border-radius: 4px; 44 | } 45 | 46 | .container { 47 | padding: 0 40px; 48 | } 49 | 50 | .list { 51 | display: flex; 52 | flex-direction: column; 53 | } 54 | 55 | .listItem { 56 | margin-bottom: 10px; 57 | border-radius: 8px; 58 | border: 1px solid grey; 59 | } 60 | 61 | .timer { 62 | margin: 40px 0 0 0; 63 | } 64 | 65 | .imageList { 66 | display: flex; 67 | flex-wrap: wrap; 68 | gap: 20px; 69 | } 70 | 71 | .content { 72 | margin-top: 40px; 73 | } 74 | 75 | .tabs { 76 | display: flex; 77 | flex-wrap: wrap; 78 | column-gap: 40px; 79 | justify-content: center; 80 | cursor: pointer; 81 | } 82 | 83 | .tabName { 84 | opacity: .3; 85 | } 86 | 87 | .tabName-active { 88 | opacity: 1; 89 | } 90 | 91 | .tabName:hover { 92 | opacity: .7; 93 | } 94 | 95 | .imgPreview { 96 | width: 100%; 97 | } 98 | 99 | .imageList { 100 | justify-content: center; 101 | } 102 | 103 | .imageListItem { 104 | display: flex; 105 | justify-content: center; 106 | align-items: center; 107 | } 108 | 109 | @media only screen and (min-width : 320px) { 110 | .imageListItem { 111 | width: 20%; 112 | } 113 | 114 | .tabName { 115 | font-size: 22px; 116 | } 117 | } 118 | 119 | @media only screen and (min-width : 768px) { 120 | .imageListItem { 121 | width: 100px; 122 | } 123 | 124 | .tabName { 125 | font-size: 34px; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react'; 2 | import RequestDemo from './components/RequestDemo' 3 | import ImageDemo from './components/ImageDemo' 4 | import './App.css'; 5 | 6 | type Section = 'useCancelableRequest' | 'useCancelableImg'; 7 | 8 | const SectionComponent = { 9 | useCancelableRequest: RequestDemo, 10 | useCancelableImg: ImageDemo, 11 | } 12 | 13 | function App() { 14 | const [isListVisible, setIsListVisible] = useState(true) 15 | const [activeSection, setActiveSection] = useState
('useCancelableRequest') 16 | const spanRef = useRef(null) 17 | const timerRef = useRef | null>(null) 18 | 19 | const DemoComponent = SectionComponent[activeSection] 20 | const sectionNames = Object.keys(SectionComponent) 21 | 22 | function setHideTimeout(sec: number) { 23 | if (spanRef?.current) { 24 | spanRef.current.innerText = sec.toString(); 25 | } 26 | 27 | if (sec > 0) { 28 | timerRef.current = setTimeout(() => { 29 | setHideTimeout(--sec); 30 | }, 1000); 31 | } else { 32 | setIsListVisible(false) 33 | } 34 | } 35 | 36 | useEffect(() => { 37 | setIsListVisible(true); 38 | timerRef.current && clearInterval(timerRef.current) 39 | setHideTimeout(5) 40 | // eslint-disable-next-line react-hooks/exhaustive-deps 41 | }, [activeSection]) 42 | return ( 43 |
44 |
45 |

react-cancelable

46 | Open Network tab to be sure all pending requests will be canceled 47 |
48 |
49 |
50 | Requests will be canceled in 51 |
52 |
53 |
54 | {sectionNames.map(key => ( 55 |

setActiveSection(key as Section)} 59 | > 60 | {key} 61 |

62 | ))} 63 |
64 |
65 | { 66 | !isListVisible &&

List is unmounted. Pending requests are canceled.

67 | } 68 | { 69 | isListVisible && 70 | } 71 |
72 |
73 | ); 74 | } 75 | 76 | export default App; 77 | -------------------------------------------------------------------------------- /example/src/components/ImageDemo.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useCancelableImg } from 'react-cancelable' 3 | import axios from 'axios' 4 | 5 | const ITEMS = new Array(50).fill(null) 6 | 7 | function getImage(controller: AbortController) { 8 | return axios('https://picsum.photos/5000', { signal: controller.signal, responseType: 'blob' }) 9 | // return fetch('https://picsum.photos/5000', { signal: controller.signal }) 10 | } 11 | 12 | const Item: FC = () => { 13 | const { src, isLoading } = useCancelableImg(getImage) 14 | 15 | return ( 16 |
17 | {isLoading && Loading...} 18 | {src && Fetched } 19 |
20 | ) 21 | } 22 | 23 | function ImageDemo() { 24 | return ( 25 |
26 |
27 | { 28 | ITEMS.map((el, i) => ) 29 | } 30 |
31 |
32 | ); 33 | } 34 | 35 | export default ImageDemo; 36 | -------------------------------------------------------------------------------- /example/src/components/RequestDemo.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useCancelableReq } from 'react-cancelable' 3 | import axios from 'axios' 4 | 5 | const ITEMS = new Array(50).fill(null) 6 | 7 | // https://httpbin.org/bytes/rr - Error 404 with body 8 | // https://httpbin.org/delay/${randomInteger(1,5)} - random delay with body 9 | 10 | function randomInteger(min: number, max: number) { 11 | return Math.floor(Math.random() * (max - min + 1)) + min; 12 | } 13 | 14 | function getSomething(controller: AbortController) { 15 | return axios(`https://httpbin.org/delay/${randomInteger(1,5)}`, { signal: controller.signal }) 16 | // return fetch(`https://httpbin.org/bytes/rr`, { signal: controller.signal }) 17 | } 18 | 19 | const Item: FC = () => { 20 | const { res, isLoading, error } = useCancelableReq(getSomething) 21 | 22 | return ( 23 |
24 | {isLoading && Loading...} 25 |
26 | {res && Data fetched!} 27 |
28 | {error && {error.message || error.code || 'Error'}} 29 |
30 | ) 31 | } 32 | 33 | function RequestDemo() { 34 | return ( 35 |
36 |
37 | { 38 | ITEMS.map((el, i) => ) 39 | } 40 |
41 |
42 | ); 43 | } 44 | 45 | export default RequestDemo; 46 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0 15px; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const container = document.getElementById('root'); 8 | const root = createRoot(container!); 9 | root.render(); 10 | 11 | // If you want to start measuring performance in your app, pass a function 12 | // to log results (for example: reportWebVitals(console.log)) 13 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 14 | reportWebVitals(); 15 | -------------------------------------------------------------------------------- /example/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /example/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/src/tests/cancelable.axios.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks' 2 | import { useCancelableReq, useCancelableImg } from 'react-cancelable' 3 | import { getSomething } from './utils' 4 | import axios from 'axios' 5 | 6 | const CANCEL_ERROR_STRING = 'Cancel: canceled' 7 | 8 | beforeAll(() => { 9 | global.URL.createObjectURL = blob => blob.toString(); 10 | }) 11 | 12 | test('useCancelableImg (axios)', async () => { 13 | const { result, waitForNextUpdate } = renderHook(() => useCancelableImg( 14 | getSomething(axios, 'https://picsum.photos/50', { responseType: 'blob'})) 15 | ) 16 | 17 | await waitForNextUpdate({ timeout: 2000 }) 18 | 19 | expect(result.current.src).toBe(new Blob().toString()) 20 | }) 21 | 22 | // _______________ 23 | 24 | test('useCancelableReq (axios -> get json)', async () => { 25 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(axios, 'https://httpbin.org/json'))) 26 | 27 | await waitForNextUpdate({ timeout: 2000 }) 28 | 29 | 30 | expect(typeof result.current.data).toBe('object') 31 | expect(result.current.res.status).toBe(200) 32 | }) 33 | 34 | test('useCancelableReq (axios -> get text)', async () => { 35 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(axios, 'https://httpbin.org/robots.txt'))) 36 | 37 | await waitForNextUpdate({ timeout: 2000 }) 38 | 39 | expect(typeof result.current.data).toBe('string') 40 | expect(result.current.res.status).toBe(200) 41 | }) 42 | 43 | test('useCancelableReq (axios -> get blob)', async () => { 44 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 45 | getSomething(axios, 'https://picsum.photos/50', { responseType: 'blob'})) 46 | ) 47 | 48 | await waitForNextUpdate({ timeout: 2000 }) 49 | 50 | expect(result.current.data instanceof Blob).toBe(true) 51 | expect(result.current.res.status).toBe(200) 52 | }) 53 | 54 | test('useCancelableReq (axios -> get error without body)', async () => { 55 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(axios, 'https://httpbin.org/status/400'))) 56 | 57 | await waitForNextUpdate({ timeout: 2000 }) 58 | 59 | expect(result.current.res.status).toBe(400) 60 | }) 61 | 62 | test('useCancelableReq (axios -> get error with body)', async () => { 63 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(axios, 'https://httpbin.org/range/_dummy_'))) 64 | 65 | await waitForNextUpdate({ timeout: 2000 }) 66 | 67 | expect(typeof result.current.error).toBe('string') 68 | }) 69 | 70 | test('useCancelableReq (axios -> opts.isLazy)', async () => { 71 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 72 | getSomething(axios, 'https://httpbin.org/json'), 73 | { 74 | isLazy: true 75 | } 76 | )) 77 | 78 | act(() => { 79 | result.current.makeLazyRequest() 80 | }) 81 | 82 | await waitForNextUpdate({ timeout: 2000 }) 83 | 84 | expect(typeof result.current.data).toBe('object') 85 | expect(result.current.res.status).toBe(200) 86 | }) 87 | 88 | test('useCancelableReq (axios -> opts.controller)', async () => { 89 | const controller = new AbortController() 90 | 91 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 92 | getSomething(axios, 'https://httpbin.org/json'), 93 | { 94 | controller 95 | } 96 | )) 97 | 98 | controller.abort() 99 | 100 | await waitForNextUpdate({ timeout: 2000 }) 101 | 102 | expect(result.current.error.toString()).toBe(CANCEL_ERROR_STRING) 103 | }) 104 | 105 | test('useCancelableImg (axios -> artefacts.cancel)', async () => { 106 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 107 | getSomething(axios, 'https://httpbin.org/json') 108 | )) 109 | 110 | act(() => { 111 | result.current.cancel() 112 | }) 113 | 114 | await waitForNextUpdate({ timeout: 2000 }) 115 | 116 | expect(result.current.error.toString()).toBe(CANCEL_ERROR_STRING) 117 | }) 118 | 119 | -------------------------------------------------------------------------------- /example/src/tests/cancelable.fetch.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks' 2 | import { useCancelableReq, useCancelableImg } from 'react-cancelable' 3 | import { getSomething } from './utils' 4 | 5 | const CANCEL_ERROR_STRING = 'AbortError: Aborted' 6 | 7 | beforeAll(() => { 8 | global.URL.createObjectURL = blob => blob.toString(); 9 | }) 10 | 11 | test('useCancelableImg (fetch)', async () => { 12 | const { result, waitForNextUpdate } = renderHook(() => useCancelableImg(getSomething(fetch, 'https://picsum.photos/50'))) 13 | 14 | await waitForNextUpdate({ timeout: 2000 }) 15 | 16 | expect(result.current.src).toBe(new Blob().toString()) 17 | }) 18 | 19 | // ______________ 20 | 21 | test('useCancelableReq (fetch -> get json)', async () => { 22 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(fetch, 'https://httpbin.org/json'))) 23 | 24 | await waitForNextUpdate({ timeout: 2000 }) 25 | 26 | expect(typeof result.current.data).toBe('object') 27 | expect(result.current.res.status).toBe(200) 28 | }) 29 | 30 | test('useCancelableReq (fetch -> get text)', async () => { 31 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(fetch, 'https://httpbin.org/robots.txt'))) 32 | 33 | await waitForNextUpdate({ timeout: 2000 }) 34 | 35 | expect(typeof result.current.data).toBe('string') 36 | expect(result.current.res.status).toBe(200) 37 | }) 38 | 39 | test('useCancelableReq (fetch -> get blob)', async () => { 40 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(fetch, 'https://picsum.photos/50'))) 41 | 42 | await waitForNextUpdate({ timeout: 2000 }) 43 | 44 | expect(result.current.data instanceof Blob).toBe(true) 45 | expect(result.current.res.status).toBe(200) 46 | }) 47 | 48 | test('useCancelableReq (fetch -> get error without body)', async () => { 49 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(fetch, 'https://httpbin.org/status/400'))) 50 | 51 | await waitForNextUpdate({ timeout: 2000 }) 52 | 53 | expect(result.current.res.status).toBe(400) 54 | }) 55 | 56 | test('useCancelableReq (fetch -> get error with body)', async () => { 57 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq(getSomething(fetch, 'https://httpbin.org/range/_dummy_'))) 58 | 59 | await waitForNextUpdate({ timeout: 2000 }) 60 | 61 | expect(typeof result.current.error).toBe('string') 62 | }) 63 | 64 | test('useCancelableReq (fetch -> opts.isLazy)', async () => { 65 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 66 | getSomething(fetch, 'https://httpbin.org/json'), 67 | { 68 | isLazy: true 69 | } 70 | )) 71 | 72 | act(() => { 73 | result.current.makeLazyRequest() 74 | }) 75 | 76 | await waitForNextUpdate({ timeout: 2000 }) 77 | 78 | expect(typeof result.current.data).toBe('object') 79 | expect(result.current.res.status).toBe(200) 80 | }) 81 | 82 | test('useCancelableImg (fetch -> opts.controller)', async () => { 83 | const controller = new AbortController() 84 | 85 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 86 | getSomething(fetch, 'https://httpbin.org/json'), 87 | { 88 | controller 89 | } 90 | )) 91 | 92 | controller.abort() 93 | 94 | await waitForNextUpdate({ timeout: 2000 }) 95 | 96 | expect(result.current.error.toString()).toBe(CANCEL_ERROR_STRING) 97 | }) 98 | 99 | test('useCancelableImg (fetch -> artefacts.cancel)', async () => { 100 | const { result, waitForNextUpdate } = renderHook(() => useCancelableReq( 101 | getSomething(fetch, 'https://httpbin.org/json') 102 | )) 103 | 104 | act(() => { 105 | result.current.cancel() 106 | }) 107 | 108 | await waitForNextUpdate({ timeout: 2000 }) 109 | 110 | expect(result.current.error.toString()).toBe(CANCEL_ERROR_STRING) 111 | }) 112 | -------------------------------------------------------------------------------- /example/src/tests/types.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks' 2 | import { useCancelableReq, useCancelableImg, cancelable } from 'react-cancelable' 3 | 4 | function getSomething(controller) { 5 | return fetch(`https://httpbin.org/get`, { signal: controller.signal }) 6 | } 7 | 8 | test('validate useCancelableReq return type', async () => { 9 | const { result } = renderHook(() => useCancelableReq(getSomething)) 10 | 11 | expect('data' in result.current).toBe(true) 12 | expect('error' in result.current).toBe(true) 13 | expect(result.current.isLoading).toBe(true) 14 | expect(typeof result.current.cancel).toBe('function') 15 | expect(result.current.makeLazyRequest).toBe(null) 16 | expect('makeLazyRequest' in result.current).toBe(true) 17 | }) 18 | 19 | test('validate useCancelableImg return type', async () => { 20 | const { result } = renderHook(() => useCancelableImg(getSomething)) 21 | 22 | expect('src' in result.current).toBe(true) 23 | expect('error' in result.current).toBe(true) 24 | expect(result.current.isLoading).toBe(true) 25 | expect(typeof result.current.cancel).toBe('function') 26 | expect(result.current.makeLazyRequest).toBe(null) 27 | expect('makeLazyRequest' in result.current).toBe(true) 28 | }) 29 | 30 | test('validate cancelable return type', async () => { 31 | const request = cancelable(getSomething) 32 | 33 | expect(request instanceof Promise).toBe(true) 34 | expect(typeof request.cancel).toBe('function') 35 | }) 36 | -------------------------------------------------------------------------------- /example/src/tests/utils.js: -------------------------------------------------------------------------------- 1 | export function getSomething(client, url, opts) { 2 | return function(controller) { 3 | return client(url, { signal: controller ? controller.signal : undefined, ...opts }) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /example/src/tests/utils.test.js: -------------------------------------------------------------------------------- 1 | import { getResParser, getCTypeHeaderVal, cancelable } from 'react-cancelable' 2 | import axios from 'axios' 3 | 4 | test('getCTypeHeaderVal', async () => { 5 | const fetchRes = await fetch('https://httpbin.org/get') 6 | const axiosRes = await axios.get('https://httpbin.org/get') 7 | 8 | const fetchImgRes = await fetch('https://picsum.photos/200') 9 | const axiosImgRes = await axios.get('https://picsum.photos/200') 10 | 11 | expect(getCTypeHeaderVal(fetchRes)).toBe('application/json') 12 | expect(getCTypeHeaderVal(axiosRes)).toBe('application/json') 13 | 14 | expect(getCTypeHeaderVal(fetchImgRes)).toBe('image/jpeg') 15 | expect(getCTypeHeaderVal(axiosImgRes)).toBe('image/jpeg') 16 | }) 17 | 18 | test('getResParser', async () => { 19 | const fetchRes = await fetch('https://httpbin.org/get') 20 | const axiosRes = await axios.get('https://httpbin.org/get') 21 | const fetchImgRes = await fetch('https://picsum.photos/200') 22 | const axiosImgRes = await axios.get('https://picsum.photos/200') 23 | 24 | const fetchCT = getCTypeHeaderVal(fetchRes) 25 | const axiosCT = getCTypeHeaderVal(axiosRes) 26 | const fetchImgCT = getCTypeHeaderVal(fetchImgRes) 27 | const axiosImgCT = getCTypeHeaderVal(axiosImgRes) 28 | 29 | expect(getResParser(fetchCT)).toBe('json') 30 | expect(getResParser(axiosCT)).toBe('json') 31 | expect(getResParser(fetchImgCT)).toBe('blob') 32 | expect(getResParser(axiosImgCT)).toBe('blob') 33 | expect(getResParser('application/json')).toBe('json') 34 | expect(getResParser('text/xml')).toBe('text') 35 | expect(getResParser('image/png')).toBe('blob') 36 | expect(getResParser('video/mp4')).toBe('blob') 37 | expect(getResParser('???')).toBe('blob') 38 | }) 39 | 40 | test('cancelable | wait', async () => { 41 | const fetchReq = cancelable((controller) => fetch('https://httpbin.org/get', { signal: controller.signal })) 42 | const axiosReq = cancelable((controller) => axios.get('https://httpbin.org/get', { signal: controller.signal })) 43 | 44 | const results = await Promise.allSettled([axiosReq, fetchReq]) 45 | 46 | expect(results.every(res => res.status === 'fulfilled')).toBe(true) 47 | }) 48 | 49 | test('cancelable | reject', async () => { 50 | const fetchReq = cancelable((controller) => fetch('https://httpbin.org/get', { signal: controller.signal })) 51 | const axiosReq = cancelable((controller) => axios.get('https://httpbin.org/get', { signal: controller.signal })) 52 | 53 | axiosReq.cancel() 54 | fetchReq.cancel() 55 | 56 | const results = await Promise.allSettled([axiosReq, fetchReq]) 57 | 58 | expect(results.every(res => res.status === 'rejected')).toBe(true) 59 | }) 60 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-cancelable", 3 | "version": "1.0.4", 4 | "main": "./lib/cjs/index.js", 5 | "module": "./lib/esm/index.js", 6 | "types": "./lib/esm/index.d.ts", 7 | "license": "MIT", 8 | "keywords": [ 9 | "cancelable", 10 | "request", 11 | "hooks", 12 | "react" 13 | ], 14 | "description": "Make cancelable requests with react-hooks", 15 | "author": "Vladyslav Ohirenko ", 16 | "homepage": "https://vladagurets.github.io/react-cancelable/", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/vladagurets/react-cancelable/" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/vladagurets/react-cancelable/issues", 23 | "email": "aguretsvlad@gmail.com" 24 | }, 25 | "scripts": { 26 | "build:cjs": "tsc --module commonjs --outDir lib/cjs", 27 | "build:esm": "tsc", 28 | "build": "yarn build:esm && yarn build:cjs", 29 | "pub": "yarn test --watchAll=false && yarn publish", 30 | "start": "yarn build && yarn --cwd ./example start", 31 | "test:lazy": "yarn --cwd ./example test", 32 | "test": "yarn build && yarn test:lazy", 33 | "watch": "tsc -w" 34 | }, 35 | "peerDependencies": { 36 | "react": ">=16.8", 37 | "react-dom": ">=16.8" 38 | }, 39 | "devDependencies": { 40 | "@types/react": "^18.0.21", 41 | "@types/react-dom": "^18.0.6", 42 | "react": "^18.2.0", 43 | "react-dom": "^18.2.0", 44 | "typescript": "^4.8.4" 45 | }, 46 | "files": [ 47 | "/lib" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://www.supportukraine.co/) 2 | 3 |

react-cancelable

4 |

Internet traffic economizer 5 |

6 | 7 |
8 | 9 | [![version][npm-version-badge]][npm-url] 10 | [![downloads][weekly-downloads-badge]][npm-url] 11 | 12 | 13 | [npm-url]: https://www.npmjs.com/package/react-cancelable 14 | [npm-version-badge]: https://badge.fury.io/js/react-cancelable.svg 15 | [total-downloads-badge]: https://img.shields.io/npm/dt/react-cancelable.svg 16 | [weekly-downloads-badge]: https://img.shields.io/npm/dm/react-cancelable.svg 17 | 18 |
19 | 20 | # Table of Contents 21 | 22 | 1. [Motivation](#motivation) 23 | 2. [Instalation](#instalation) 24 | 3. [Tools](#tools) 25 | 1. [useCancelableReq](#usecancelablereq) 26 | 2. [useCancelableImg](#usecancelableimg) 27 | 3. [cancelable HOF](#cancelable-hof) 28 | 4. [Fetch vs Axios](#fetch-vs-axios) 29 | 5. [Best practices (WIP)](#best-practices) 30 | 31 |
32 | 33 | # Motivation 34 | 35 | In most of cases client consumes a lot of excess internet traffic. Modern web applications make a huge bunch of requests per conventional time unit then a lot of clients don't wait until all requests made by web app are finished. As a result, the browser expects data that will no longer be used. 36 | 37 | 38 | 39 | With react-cancelable you can easily cancel requests at any step of the [request's lifecycle](https://dev.to/dangolant/things-i-brushed-up-on-this-week-the-http-request-lifecycle-) and consume fewer traffic bytes. 40 | 41 |
42 | 43 | # Instalation 44 | 45 | ``` 46 | npm install react-cancelable 47 | ``` 48 | ``` 49 | yarn add react-cancelable 50 | ``` 51 | 52 |
53 | 54 | Before installation be sure you have installed the required peer dependencies to your project 55 | 56 |
57 | 58 | ```json 59 | { 60 | "react": "^17.0.0", 61 | } 62 | ``` 63 | 64 |
65 | 66 | # Tools 67 | 68 |
69 | 70 | ## useCancelableReq 71 | 72 |
73 | 74 | Make cancelable request. Hook helps you to control request canceling by React Component Lifecycle or by your own. 75 | 76 |
77 | 78 | ### Signature 79 | 80 |
81 | 82 | ```typescript 83 | type RequestFn = (controller: AbortController) => Promise 84 | 85 | type Opts = { 86 | isLazy?: boolean; 87 | cancelOnUnmount?: boolean; 88 | controller?: AbortController; 89 | onComplete?: (res: any) => void; 90 | onFail?: (error: any) => void 91 | onCancel?: VoidFunction; 92 | } 93 | 94 | type Artefacts = { 95 | res?: Response; 96 | data?: any; 97 | error?: any; 98 | isLoading: boolean; 99 | cancel: VoidFunction, 100 | makeLazyRequest: VoidFunction | null; 101 | } 102 | 103 | useCancelableReq(fn: RequestFn, opts?: Opts): Artefacts 104 | ``` 105 | 106 |
107 | 108 | ### API 109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 |
NameDescriptionDefault
isLazyControl request by your own if true. By default, a request will be made on the component mountfalse
cancelOnUnmountRequest will be canceled on component unmount if truetrue
controllerBy default component will create instance automaticaly under the hood. If yoo want to controll multiple requests with one conteroller pass your own instance of AbortControlerundefined
onCompleteTrigger after request is completedundefined
onFailTrigger after request is failedundefined
onCancelTrigger after request is canceledundefined
resResponse objectundefined
dataPayload of a requestundefined
errorError of a requestundefined
isLoadingFlag to determine active status of request. If isLazy is true isLoading is false by defaulttrue
cancelRequest cancel triggerfunction
makeLazyRequestMake request trigger. If isLazy is true makeLazyRequest is functionnull
181 | 182 |
183 | 184 | ### Example 185 | 186 |
187 | 188 | ```jsx 189 | import React from 'react' 190 | import { useCancelableReq } from 'react-cancelable' 191 | 192 | function makeRequest(controller) { 193 | // Pass signal to your request 194 | return fetch("YOUR_ENDPOINT", { signal: controller.signal }) 195 | } 196 | 197 | function Item() { 198 | const { data, isLoading, error } = useCancelableReq(makeRequest) 199 | 200 | return ( 201 | <> 202 | {isLoading && Loading...} 203 | {error && Error occured} 204 | {data && Data is fetched} 205 | 206 | ) 207 | } 208 | ``` 209 | 210 |
211 | 212 | ## useCancelableImg 213 | 214 |
215 | 216 | Make cancelable request. Hook helps you to cancel requested image. 217 | 218 |
219 | 220 | ### Signature 221 | 222 |
223 | 224 | ```typescript 225 | type RequestFn = (controller: AbortController) => Promise 226 | 227 | type Opts = { 228 | isLazy?: boolean; 229 | cancelOnUnmount?: boolean; 230 | controller?: AbortController; 231 | onComplete?: (res: any) => void; 232 | onFail?: (error: any) => void 233 | onCancel?: VoidFunction; 234 | } 235 | 236 | type Artefacts = { 237 | res?: Response; 238 | src?: string; 239 | error?: any; 240 | isLoading: boolean; 241 | cancel: VoidFunction, 242 | makeLazyRequest: VoidFunction | null; 243 | } 244 | 245 | useCancelableImg(fn: RequestFn, opts?: Opts): Artefacts 246 | ``` 247 | 248 |
249 | 250 | ### API 251 |
252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 |
NameDescriptionDefault
isLazyControl request by your own if true. By default, a request will be made on the component mountfalse
cancelOnUnmountRequest will be canceled on component unmount if truetrue
controllerBy default component will create instance automaticaly under the hood. If yoo want to controll multiple requests with one conteroller pass your own instance of AbortControlerundefined
onCompleteTrigger after request is completedundefined
onFailTrigger after request is failedundefined
onCancelTrigger after request is canceledundefined
resResponse objectundefined
srcGenerated ObjectURI to Blob image after request doneundefined
errorError of a requestundefined
isLoadingFlag to determine active status of request. If isLazy is true isLoading is false by defaulttrue
cancelRequest cancel triggerfunction
makeLazyRequestMake request trigger. If isLazy is true makeLazyRequest is functionnull
323 | 324 |
325 | 326 | ### Example 327 | 328 |
329 | 330 | ```jsx 331 | import React from 'react' 332 | import { useCancelableReq } from 'react-cancelable' 333 | 334 | 335 | function getImage(controller) { 336 | // Pass signal to your request 337 | return fetch('IMAGE_URL', { signal: controller.signal }) 338 | } 339 | 340 | function Item() { 341 | const { src, isLoading, error } = useCancelableImg(getImage) 342 | 343 | return ( 344 | <> 345 | {isLoading && Loading...} 346 | {src && } 347 | 348 | ) 349 | } 350 | ``` 351 | 352 |
353 | 354 | ## cancelable HOF 355 |
356 | 357 | Hight order function to create cancelable requests 358 | 359 |
360 | 361 | ### Signature 362 | 363 |
364 | 365 | ```typescript 366 | type RequestFn = (controller: AbortController) => Promise 367 | 368 | type RequestPromise = Promise & { cancel: VoidFunction } 369 | 370 | cancelable(fn: RequestFn, controller?: AbortController): RequestPromise 371 | ``` 372 | 373 |
374 | 375 | ### API 376 | 377 |
378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 |
NameDescriptionDefault
fnCallback that returns Promise generated by HTTP clientfunction
controllerBy default component will create instance automaticaly under the hood. If yoo want to controll multiple requests with one conteroller pass your own instance of AbortControlerundefined
cancelRequest cancel trigger. Property added to returned Promisefunction
405 | 406 |
407 | 408 | ### Example 409 | 410 |
411 | 412 | ```javascript 413 | import { cancelable } from 'react-cancelable' 414 | 415 | function makeRequest(controller) { 416 | return fetch("YOUR_ENDPOINT", { signal: controller.signal }) 417 | } 418 | 419 | // Wrap your request 420 | const request = cancelable(makeRequest) 421 | 422 | setTimeout(() => { 423 | // Cancel request later 424 | request.cancel() 425 | }, 1000) 426 | ``` 427 | 428 |
429 | 430 | # Fetch vs Axios 431 | 432 | There is no difference what HTTP client you use. Package have one important rule - HTTP client must accept [AbortController signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal). 433 | 434 | ```javascript 435 | function makeFetchRequest(controller) { 436 | return fetch("YOUR_ENDPOINT", { signal: controller.signal }) 437 | } 438 | 439 | function makeAxiosRequest(controller) { 440 | return axios.get("YOUR_ENDPOINT", { signal: controller.signal }) 441 | } 442 | ``` 443 | 444 |
445 | 446 | # Best practices (WIP) 447 | 448 | Cancel multiple similar request via one AbortController. Each helper can take ```controller``` parameter. 449 | 450 | ```javascript 451 | import { cancelable } from 'react-cancelable' 452 | 453 | const controller = new AbortController() 454 | 455 | function makeRequest(controller) { 456 | return fetch("YOUR_ENDPOINT", { signal: controller.signal }) 457 | } 458 | 459 | // Make requests 460 | new Array(100).fill(0).forEach(() => { cancelable(makeRequest, controller) } ) 461 | 462 | setTimeout(() => { 463 | // Stop all pending requests 464 | controller.abort() 465 | }, 1000) 466 | ``` 467 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | export { default as useCancelableReq } from './useCancelableReq'; 3 | export { default as useCancelableImg } from './useCancelableImg'; 4 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type UseCancelableReqParams = { 2 | isLazy?: boolean; 3 | cancelOnUnmount?: boolean; 4 | controller?: AbortController; 5 | onFail?: (error: any) => void; 6 | onComplete?: (res: any) => void; 7 | onCancel?: () => void; 8 | } 9 | 10 | type BaseCancelableReturn = { 11 | res?: Response; 12 | error?: Error; 13 | isLoading: boolean; 14 | cancel: VoidFunction; 15 | makeLazyRequest: VoidFunction | null; 16 | } 17 | 18 | type UseCancelableReqReturn = BaseCancelableReturn & { 19 | data?: Response; 20 | } 21 | 22 | type UseCancelableImgReturn = BaseCancelableReturn & { 23 | src?: string; 24 | } 25 | 26 | type RejectOrCbOpts = { 27 | isMounted: boolean; 28 | data: any; 29 | } 30 | 31 | type CancelableRequestFn = (controller: AbortController) => Promise 32 | 33 | type ExtendedPromise = Promise & { 34 | cancel: VoidFunction 35 | } 36 | -------------------------------------------------------------------------------- /src/useCancelableImg.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import useCancelableReq from "./useCancelableReq"; 3 | 4 | /** 5 | * @description Hook to prepare cancelable image request. 6 | * `fn` must return a Request with Blob response 7 | */ 8 | export default function useCancelableImg(fn: CancelableRequestFn, opts?: UseCancelableReqParams): UseCancelableImgReturn { 9 | const { 10 | isLazy = false, 11 | cancelOnUnmount = true, 12 | controller, 13 | onComplete, 14 | onFail, 15 | onCancel 16 | } = opts || {}; 17 | 18 | const { isLoading, error, cancel, makeLazyRequest } = useCancelableReq(fn, { 19 | controller, 20 | cancelOnUnmount, 21 | isLazy, 22 | onComplete: handleComplete, 23 | onFail, 24 | onCancel 25 | }); 26 | const [src, setSrc] = useState() 27 | 28 | function handleComplete(blob: Blob) { 29 | if (!(blob instanceof Blob)) { 30 | throw Error('[react-cancelable] useCancelableImg: Request must return Blob') 31 | } 32 | 33 | const imageObjectURL = URL.createObjectURL(blob); 34 | setSrc(imageObjectURL) 35 | onComplete?.(imageObjectURL) 36 | } 37 | 38 | return { 39 | src, 40 | error, 41 | isLoading, 42 | cancel, 43 | makeLazyRequest 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/useCancelableReq.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo, useRef } from "react"; 2 | import { rejectOrCb, getResParser, getCTypeHeaderVal, getErrorBody } from "./utils"; 3 | 4 | export default function useCancelableReq(fn: CancelableRequestFn, opts?: UseCancelableReqParams): UseCancelableReqReturn { 5 | const { 6 | isLazy = false, 7 | cancelOnUnmount = true, 8 | controller, 9 | onComplete, 10 | onFail, 11 | onCancel 12 | } = opts || {}; 13 | 14 | const [isLoading, setIsLoading] = useState(!isLazy); 15 | const resRef = useRef(); 16 | const [resData, setResData] = useState(); 17 | const [error, setError] = useState(); 18 | 19 | const abortController = useMemo(() => controller || new AbortController(), []) 20 | 21 | function setResponse(response: Response) { 22 | resRef.current = response 23 | } 24 | 25 | function handleSetError(data: any) { 26 | const errorBody = getErrorBody(data) 27 | 28 | // Set response object for axios 29 | if (data.request) { 30 | setResponse(data) 31 | } 32 | 33 | setError(errorBody); 34 | setIsLoading(false); 35 | onFail?.(errorBody) 36 | } 37 | 38 | function handleSetResData(data: any) { 39 | setResData(data) 40 | setIsLoading(false) 41 | onComplete?.(data) 42 | } 43 | 44 | function cancel(): void { 45 | abortController.abort() 46 | onCancel?.() 47 | } 48 | 49 | function processResult(response: Response, isMounted: boolean) { 50 | const contentheader = getCTypeHeaderVal(response) 51 | const resHandler = getResParser(contentheader) 52 | 53 | resRef.current = response; 54 | 55 | if (response['data']) { 56 | // Get `axios` res data 57 | rejectOrCb(handleSetResData, { isMounted, data: response['data'] }) 58 | } else { 59 | // Get `fetch` res data 60 | response[resHandler]() 61 | .then((data: any) => rejectOrCb(response.ok ? handleSetResData : handleSetError, { isMounted, data })) 62 | .catch((error: any) => rejectOrCb(handleSetError, { isMounted, data: error })) 63 | } 64 | } 65 | 66 | function makeRequest() { 67 | setIsLoading(true) 68 | 69 | fn(abortController) 70 | .then(response => rejectOrCb(processResult, { isMounted: true, data: response })) 71 | .catch((error: any) => rejectOrCb(handleSetError, { isMounted: true, data: error.response || error })) 72 | } 73 | 74 | useEffect(() => { 75 | let isMounted = true; 76 | 77 | if (!isLazy) { 78 | fn(abortController) 79 | .then(response => rejectOrCb(processResult, { isMounted, data: response })) 80 | .catch((error: any) => rejectOrCb(handleSetError, { isMounted, data: error.response || error })) 81 | } 82 | 83 | if (cancelOnUnmount) { 84 | return () => { 85 | isMounted = false; 86 | 87 | if (!abortController.signal.aborted) { 88 | abortController.abort(); 89 | } 90 | }; 91 | } 92 | 93 | return; 94 | }, []) 95 | 96 | return { 97 | res: resRef.current, 98 | data: resData, 99 | error, 100 | isLoading, 101 | cancel, 102 | makeLazyRequest: isLazy ? makeRequest : null 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | const RES_TYPES_TO_PARSER = { 2 | 'json': 'json', 3 | 'text': 'text', 4 | default: 'blob' 5 | } 6 | 7 | const C_TYPE_HEADER_KEY = 'content-type'; 8 | 9 | export function getResParser(contentType: string): string { 10 | if (contentType.includes('application/json')) { 11 | return RES_TYPES_TO_PARSER.json 12 | } 13 | 14 | const type = contentType.split('/').shift() 15 | return type && RES_TYPES_TO_PARSER[type] || RES_TYPES_TO_PARSER.default; 16 | } 17 | 18 | export function stopPromiseChain() { 19 | return Promise.resolve(); 20 | } 21 | 22 | export function rejectOrCb(cb: Function, opts: RejectOrCbOpts) { 23 | const { isMounted, data } = opts 24 | if (isMounted) { 25 | cb(data, isMounted) 26 | } else { 27 | stopPromiseChain() 28 | } 29 | } 30 | 31 | export function getCTypeHeaderVal(response: Response): string { 32 | let headerVal 33 | 34 | if (response?.headers?.get) { 35 | headerVal = response.headers.get(C_TYPE_HEADER_KEY) 36 | } else if (response?.headers) { 37 | headerVal = response.headers[C_TYPE_HEADER_KEY] 38 | } 39 | 40 | return headerVal || 'default'; 41 | } 42 | 43 | /** 44 | * @description HOF for requests 45 | */ 46 | export function cancelable(fn: CancelableRequestFn, controller?: AbortController): ExtendedPromise { 47 | const _controller = controller || new AbortController() 48 | 49 | const promise = fn(_controller) 50 | 51 | promise['cancel'] = () => _controller.abort() 52 | 53 | return promise as ExtendedPromise 54 | } 55 | 56 | export function getErrorBody(data: any) { 57 | if (typeof data === 'object' && 'data' in data) { 58 | return data.data 59 | } 60 | 61 | return data 62 | } 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib/esm", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "moduleResolution": "node", 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "esModuleInterop": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "allowSyntheticDefaultImports": true, 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "lib"] 22 | } 23 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/prop-types@*": 6 | version "15.7.5" 7 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" 8 | integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== 9 | 10 | "@types/react-dom@^18.0.6": 11 | version "18.0.6" 12 | resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" 13 | integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA== 14 | dependencies: 15 | "@types/react" "*" 16 | 17 | "@types/react@*", "@types/react@^18.0.21": 18 | version "18.0.21" 19 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.21.tgz#b8209e9626bb00a34c76f55482697edd2b43cc67" 20 | integrity sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA== 21 | dependencies: 22 | "@types/prop-types" "*" 23 | "@types/scheduler" "*" 24 | csstype "^3.0.2" 25 | 26 | "@types/scheduler@*": 27 | version "0.16.2" 28 | resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" 29 | integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== 30 | 31 | csstype@^3.0.2: 32 | version "3.1.1" 33 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" 34 | integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== 35 | 36 | "js-tokens@^3.0.0 || ^4.0.0": 37 | version "4.0.0" 38 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 39 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 40 | 41 | loose-envify@^1.1.0: 42 | version "1.4.0" 43 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 44 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 45 | dependencies: 46 | js-tokens "^3.0.0 || ^4.0.0" 47 | 48 | react-dom@^18.2.0: 49 | version "18.2.0" 50 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" 51 | integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== 52 | dependencies: 53 | loose-envify "^1.1.0" 54 | scheduler "^0.23.0" 55 | 56 | react@^18.2.0: 57 | version "18.2.0" 58 | resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" 59 | integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== 60 | dependencies: 61 | loose-envify "^1.1.0" 62 | 63 | scheduler@^0.23.0: 64 | version "0.23.0" 65 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" 66 | integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== 67 | dependencies: 68 | loose-envify "^1.1.0" 69 | 70 | typescript@^4.8.4: 71 | version "4.8.4" 72 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6" 73 | integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ== 74 | --------------------------------------------------------------------------------