├── src ├── index.ts ├── useCurrentEffect.ts ├── useCurrentCallback.ts ├── useCurrentCallback.test.tsx └── useCurrentEffect.test.tsx ├── dist ├── index.d.ts ├── index.js.map ├── index.js ├── useCurrentEffect.d.ts ├── useCurrentCallback.d.ts ├── useCurrentCallback.js.map ├── useCurrentEffect.js.map ├── useCurrentCallback.js └── useCurrentEffect.js ├── jest.config.js ├── tsconfig.jest.json ├── tsconfig.json ├── .gitignore ├── package.json ├── LICENSE └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCurrentEffect"; 2 | export * from "./useCurrentCallback"; -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCurrentEffect"; 2 | export * from "./useCurrentCallback"; 3 | -------------------------------------------------------------------------------- /dist/index.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;AAAA,wCAAmC;AACnC,0CAAqC"} -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | globals: { 4 | 'ts-jest': { 5 | tsConfig: 'tsconfig.jest.json' 6 | } 7 | }, 8 | transform: { 9 | "^.+\\.tsx?$": "ts-jest" 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | function __export(m) { 3 | for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; 4 | } 5 | exports.__esModule = true; 6 | __export(require("./useCurrentEffect")); 7 | __export(require("./useCurrentCallback")); 8 | //# sourceMappingURL=index.js.map -------------------------------------------------------------------------------- /dist/useCurrentEffect.d.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList } from "react"; 2 | declare type CheckCurrent = () => boolean; 3 | export declare function useCurrentEffect(callback: ((isCurrent: CheckCurrent) => void) | ((isCurrent: CheckCurrent) => () => void), deps?: DependencyList): void; 4 | export {}; 5 | -------------------------------------------------------------------------------- /dist/useCurrentCallback.d.ts: -------------------------------------------------------------------------------- 1 | import { DependencyList } from "react"; 2 | declare type CheckCurrent = () => boolean; 3 | export declare function useCurrentCallback any>(callbackFactory: (isCurrent: CheckCurrent) => T, deps?: DependencyList): (args: any) => void; 4 | export {}; 5 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "preserveConstEnums": true, 8 | "lib": ["es2015", "dom"], 9 | "jsx": "react" 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["./node_modules/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /dist/useCurrentCallback.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"useCurrentCallback.js","sourceRoot":"","sources":["../src/useCurrentCallback.ts"],"names":[],"mappings":";;AAAA,+BAA+D;AAW/D,SAAgB,kBAAkB,CAChC,eAA+C,EAC/C,IAAqB;IAErB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrB,IAAM,YAAY,GAAG,cAAM,OAAA,SAAS,EAAT,CAAS,CAAC;IAGrC,iBAAS,CACP,cAAM,OAAA;QACJ,SAAS,GAAG,KAAK,CAAC;IACpB,CAAC,EAFK,CAEL,EACD,IAAI,CACL,CAAC;IAGF,OAAO,mBAAW,CAAC,eAAe,CAAC,YAAY,CAAC,EAAE,IAAI,CAAC,CAAC;AAC1D,CAAC;AAjBD,gDAiBC"} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "preserveConstEnums": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "declarationDir": "./dist", 11 | "outDir": "./dist", 12 | "lib": ["es6", "dom"] 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["./node_modules/**/*", "src/**/*.test.tsx"] 16 | } 17 | -------------------------------------------------------------------------------- /dist/useCurrentEffect.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"useCurrentEffect.js","sourceRoot":"","sources":["../src/useCurrentEffect.ts"],"names":[],"mappings":";;AAAA,+BAAkD;AAWlD,SAAgB,gBAAgB,CAC9B,QAAyF,EACzF,IAAqB;IAErB,iBAAS,CAAC;QACR,IAAI,SAAS,GAAG,IAAI,CAAC;QACrB,IAAM,YAAY,GAAG,cAAM,OAAA,SAAS,EAAT,CAAS,CAAC;QACrC,IAAM,OAAO,GAAG,QAAQ,CAAC,YAAY,CAAC,CAAC;QACvC,OAAO;YAEL,SAAS,GAAG,KAAK,CAAC;YAClB,OAAO,IAAI,OAAO,EAAE,CAAC;QACvB,CAAC,CAAC;IACJ,CAAC,EAAE,IAAI,CAAC,CAAC;AACX,CAAC;AAdD,4CAcC"} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | lib-cov 3 | *.seed 4 | *.log 5 | *.csv 6 | *.dat 7 | *.out 8 | *.pid 9 | *.gz 10 | *.swp 11 | 12 | pids 13 | logs 14 | results 15 | tmp 16 | 17 | # Build 18 | public/css/main.css 19 | 20 | # Coverage reports 21 | coverage 22 | 23 | # API keys and secrets 24 | .env 25 | 26 | # Dependency directory 27 | node_modules 28 | bower_components 29 | 30 | # Editors 31 | .idea 32 | *.iml 33 | 34 | # OS metadata 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # ignore yarn.lock 39 | yarn.lock -------------------------------------------------------------------------------- /dist/useCurrentCallback.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.__esModule = true; 3 | var react_1 = require("react"); 4 | function useCurrentCallback(callbackFactory, deps) { 5 | var isCurrent = true; 6 | var currentCheck = function () { return isCurrent; }; 7 | react_1.useEffect(function () { return function () { 8 | isCurrent = false; 9 | }; }, deps); 10 | return react_1.useCallback(callbackFactory(currentCheck), deps); 11 | } 12 | exports.useCurrentCallback = useCurrentCallback; 13 | //# sourceMappingURL=useCurrentCallback.js.map -------------------------------------------------------------------------------- /dist/useCurrentEffect.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.__esModule = true; 3 | var react_1 = require("react"); 4 | function useCurrentEffect(callback, deps) { 5 | react_1.useEffect(function () { 6 | var isCurrent = true; 7 | var currentCheck = function () { return isCurrent; }; 8 | var cleanup = callback(currentCheck); 9 | return function () { 10 | isCurrent = false; 11 | cleanup && cleanup(); 12 | }; 13 | }, deps); 14 | } 15 | exports.useCurrentEffect = useCurrentEffect; 16 | //# sourceMappingURL=useCurrentEffect.js.map -------------------------------------------------------------------------------- /src/useCurrentEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, DependencyList } from "react"; 2 | type CheckCurrent = () => boolean; 3 | 4 | /** 5 | * Create useEffect with a parameter to track the life of the effect 6 | * 7 | * @param callback The effect to run, it will be passed a 8 | * function that can be called to track if the effect was cleaned up 9 | * @param deps The dependencies of the effect. When they change, 10 | * the result of the current check function will be false 11 | */ 12 | export function useCurrentEffect( 13 | callback: ((isCurrent: CheckCurrent) => void) | ((isCurrent: CheckCurrent) => () => void), 14 | deps?: DependencyList 15 | ) { 16 | useEffect(() => { 17 | let isCurrent = true; 18 | const currentCheck = () => isCurrent; 19 | const cleanup = callback(currentCheck); 20 | return () => { 21 | // We set the current flag to false in the cleanup 22 | isCurrent = false; 23 | cleanup && cleanup(); 24 | }; 25 | }, deps); // eslint-disable-line react-hooks/exhaustive-deps 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-current-effect", 3 | "version": "2.1.0", 4 | "description": "useEffect hook with injected lifecycle checking method", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "build": "tsc" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/Flufd/use-current-effect.git" 13 | }, 14 | "author": "", 15 | "license": "ISC", 16 | "bugs": { 17 | "url": "https://github.com/Flufd/use-current-effect/issues" 18 | }, 19 | "homepage": "https://github.com/Flufd/use-current-effect#readme", 20 | "peerDependencies": { 21 | "react": "^16.8.0" 22 | }, 23 | "devDependencies": { 24 | "@testing-library/react": "^9.1.4", 25 | "@types/jest": "^24.0.18", 26 | "@types/react": "^16.8.0", 27 | "@types/testing-library__react": "^9.1.1", 28 | "jest": "^24.9.0", 29 | "ts-jest": "^24.0.2", 30 | "typescript": "^3.5.3", 31 | "react": "^16.8.0", 32 | "react-dom": "^16.8.0" 33 | }, 34 | "types": "dist/index.d.ts" 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Stewart Parry 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 | -------------------------------------------------------------------------------- /src/useCurrentCallback.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, DependencyList } from "react"; 2 | type CheckCurrent = () => boolean; 3 | 4 | /** 5 | * Create useCurrentCallback with a parameter to track the life of the callback 6 | * 7 | * @param callbackFactory The callback factory function, allowing injection of the 8 | * {@link CallbackState} that can be used to track if the callback's dependencies were altered 9 | * @param deps The dependencies of the effect. When they change, 10 | * the original callback's isCurrent state param will be set to false 11 | */ 12 | export function useCurrentCallback any>( 13 | callbackFactory: (isCurrent: CheckCurrent) => T, 14 | deps?: DependencyList 15 | ): (args: any) => void { 16 | let isCurrent = true; 17 | const currentCheck = () => isCurrent; 18 | 19 | // useEffect clean up to react to the dependencies changing 20 | useEffect( 21 | () => () => { 22 | isCurrent = false; 23 | }, 24 | deps // eslint-disable-line react-hooks/exhaustive-deps 25 | ); 26 | 27 | // create the callback using the factory function, injecting the current check function 28 | return useCallback(callbackFactory(currentCheck), deps); 29 | } 30 | -------------------------------------------------------------------------------- /src/useCurrentCallback.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, RenderOptions, fireEvent } from "@testing-library/react"; 3 | import { useCurrentCallback } from "./useCurrentCallback"; 4 | 5 | jest.useFakeTimers(); 6 | 7 | function renderStrict(component: React.ReactElement, options?: RenderOptions) { 8 | return render({component}, options); 9 | } 10 | 11 | describe("useCurrentCallback", () => { 12 | it("Calls the callback when called", () => { 13 | // Set up a function to check was called inside the effect 14 | const innerCallback = jest.fn(); 15 | const outerCallback = jest.fn(); 16 | 17 | // Create a test component 18 | const TestHarness: React.FC<{ id: number }> = ({ id }) => { 19 | const callBack = useCurrentCallback( 20 | isCurrent => () => { 21 | outerCallback(id); 22 | setTimeout(() => { 23 | if (isCurrent()) { 24 | innerCallback(id); 25 | } 26 | }, 100); 27 | }, 28 | [id] 29 | ); 30 | return ; 31 | }; 32 | 33 | const { container, getByRole } = renderStrict(); 34 | 35 | // Click the button to trigger the callback 36 | fireEvent.click(getByRole("button")); 37 | 38 | expect(outerCallback).toHaveBeenCalledTimes(1); 39 | expect(outerCallback).toHaveBeenLastCalledWith(1); 40 | expect(innerCallback).toHaveBeenCalledTimes(0); 41 | 42 | // Resolve the timeout 43 | jest.advanceTimersByTime(150); 44 | 45 | expect(innerCallback).toHaveBeenCalledTimes(1); 46 | expect(innerCallback).toHaveBeenLastCalledWith(1); 47 | 48 | // Click the button to trigger the callback again 49 | fireEvent.click(getByRole("button")); 50 | 51 | // Change the id, and therefore the dependencies of the callback 52 | renderStrict(, { container }); 53 | 54 | // Resolve the timeout 55 | jest.advanceTimersByTime(150); 56 | 57 | // isCurrent() now returns false, so innerCallback will not fire 58 | expect(innerCallback).toHaveBeenCalledTimes(1); 59 | expect(innerCallback).toHaveBeenLastCalledWith(1); 60 | 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # useCurrentEffect 2 | 3 | Sometimes we need to track if an effect has been cleaned up, because one of it's dependencies has changed, or the component was unmounted. The `useCurrentEffect` hook gives us a helper function as a parameter to track this state without the usual boilerplate. 4 | 5 | ## Installation 6 | 7 | `npm i use-current-effect` 8 | 9 | ## Use 10 | 11 | ```Javascript 12 | import { useCurrentEffect } from "use-current-effect"; 13 | 14 | // ... 15 | 16 | useCurrentEffect((isCurrent) => { 17 | async function fetchData() { 18 | const article = await API.fetchArticle(id); 19 | if (isCurrent()) { 20 | setArticle(article); 21 | } 22 | } 23 | 24 | fetchData(); 25 | }, [id]); 26 | ``` 27 | 28 | ## Motivation 29 | 30 | You could do this manually like this each time you want to make this check: 31 | 32 | ```JavaScript 33 | useEffect(() => { 34 | let didCancel = false; 35 | 36 | async function fetchData() { 37 | const article = await API.fetchArticle(id); 38 | if (!didCancel) { 39 | setArticle(article); 40 | } 41 | } 42 | 43 | fetchData(); 44 | 45 | return () => { 46 | didCancel = true; 47 | }; 48 | }, [id]); 49 | ``` 50 | 51 | With `useCurrentEffect` you can do away with this boilerplate and make your effects more consise. 52 | 53 | ## Callbacks 54 | 55 | There is also `useCurrentCallback` which works in a similar way, however as the consumer of the hook may want to pass parameters to the callback function, we must use a slightly different pattern. `useCurrentCallback` takes a generator function so you may inject the checker function. 56 | 57 | ```jsx 58 | const onSearchOrders = useCurrentCallback( 59 | isCurrent => searchParams => { 60 | api.searchOrders(customerId, searchParams).then(results => { 61 | if (isCurrent()) { 62 | setSearchResults(results); 63 | } 64 | }); 65 | }, 66 | [customerId] 67 | ); 68 | ``` 69 | 70 | ## ESLint 71 | 72 | If you use the ESLint rule `react-hooks/exhaustive-deps` then you can add the `useCurrentEffect` to your `additionalHooks` regex in your `.eslint` to ensure that you don't miss any dependencies. 73 | 74 | ```JSON 75 | "react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "useCurrentEffect" }], 76 | ``` 77 | -------------------------------------------------------------------------------- /src/useCurrentEffect.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, RenderOptions } from "@testing-library/react"; 3 | import { useCurrentEffect } from "./useCurrentEffect"; 4 | 5 | jest.useFakeTimers(); 6 | 7 | function renderStrict(component: React.ReactElement, options? : RenderOptions) { 8 | return render({component}, options); 9 | } 10 | 11 | describe("useCurrentEffect", () => { 12 | it("Calls the effect on initial render", () => { 13 | // Set up a function to check was called inside the effect 14 | const testEffect = jest.fn(); 15 | 16 | // Create a test component 17 | const TestHarness = () => { 18 | useCurrentEffect(() => { 19 | testEffect(); 20 | }, []); 21 | return <>; 22 | }; 23 | 24 | renderStrict(); 25 | 26 | expect(testEffect).toHaveBeenCalled(); 27 | }); 28 | 29 | it("Calls the effect when the dependencies change", () => { 30 | const testEffect = jest.fn(); 31 | 32 | const TestHarness: React.FC<{ id: number }> = ({ id }) => { 33 | useCurrentEffect(() => { 34 | testEffect(); 35 | }, [id]); 36 | 37 | return
{id}
; 38 | }; 39 | 40 | // Render 3 times with different id prop 41 | const { container } = renderStrict(); 42 | renderStrict(, { container }); 43 | renderStrict(, { container }); 44 | 45 | expect(testEffect).toHaveBeenCalledTimes(3); 46 | }); 47 | 48 | it("Does not call the effect when the dependencies don't change", () => { 49 | const testEffect = jest.fn(); 50 | 51 | const TestHarness: React.FC<{ id: number }> = ({ id }) => { 52 | useCurrentEffect(() => { 53 | testEffect(); 54 | }, [id]); 55 | 56 | return
{id}
; 57 | }; 58 | 59 | // Render 2 times with the same id prop 60 | const { container } = renderStrict(); 61 | renderStrict(, { container }); 62 | 63 | expect(testEffect).toHaveBeenCalledTimes(1); 64 | }); 65 | 66 | it("Calls the cleanup function when the dependencies change", () => { 67 | const testCleanup = jest.fn(); 68 | const testEffect = jest.fn(() => testCleanup); 69 | 70 | const TestHarness: React.FC<{ id: number }> = ({ id }) => { 71 | useCurrentEffect(() => { 72 | return testEffect(); 73 | }, [id]); 74 | 75 | return
{id}
; 76 | }; 77 | 78 | // Render 3 times with different id prop, once with the same 79 | const { container } = renderStrict(); 80 | renderStrict(, { container }); 81 | renderStrict(, { container }); 82 | renderStrict(, { container }); 83 | 84 | expect(testCleanup).toHaveBeenCalledTimes(2); 85 | }); 86 | 87 | it("Sets isCurrent result to false when the dependencies change", async () => { 88 | const spy = jest.fn(); 89 | const effectSpy = jest.fn(); 90 | 91 | const TestHarness: React.FC<{ id: number }> = ({ id }) => { 92 | useCurrentEffect( 93 | isCurrent => { 94 | effectSpy(); 95 | setTimeout(() => { 96 | if (isCurrent()) { 97 | spy(id); 98 | } 99 | }, 100); 100 | }, 101 | [id] 102 | ); 103 | 104 | return
{id}
; 105 | }; 106 | 107 | // Render with initial prop 108 | const { container } = renderStrict(); 109 | // Render again with different id prop before the timeout is resolved 110 | renderStrict(, { container }); 111 | 112 | // Resolve the timeout 113 | jest.advanceTimersByTime(150); 114 | 115 | // The side effect that hasn't been protected should be called twice 116 | expect(effectSpy).toHaveBeenCalledTimes(2); 117 | 118 | // Spy should have only been called once, with second id 119 | expect(spy).toHaveBeenCalledTimes(1); 120 | expect(spy).toHaveBeenCalledWith(2); 121 | 122 | }); 123 | }); 124 | --------------------------------------------------------------------------------