├── .gitignore ├── .swcrc ├── LICENSE ├── README.md ├── __tests__ └── basic-test.js ├── babel.config.js ├── package-lock.json ├── package.json └── src ├── index.d.ts └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | 3 | node_modules 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "jsc": { 4 | "parser": { 5 | "syntax": "ecmascript", 6 | "jsx": false 7 | }, 8 | "target": "es5", 9 | "externalHelpers": false 10 | }, 11 | "minify": true 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Cyandev 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `useMountEffect` 2 | 3 | [![npm version](https://badge.fury.io/js/use-mount-effect.svg)](https://badge.fury.io/js/use-mount-effect) 4 | 5 | A React hook triggered only once on mount. 6 | 7 | ## Background 8 | 9 | > Why does my effect run twice when the component mounts?? It's f\*\*king annoying! 10 | 11 | Well. That is not a bug in React, actually it's by design according to the [doc](https://react.dev/reference/react/useEffect#my-effect-runs-twice-when-the-component-mounts): 12 | 13 | > When Strict Mode is on, in development, React runs setup and cleanup one extra time before the actual setup. 14 | 15 | There are some workarounds to solve this problem, but people still feel it's a hassle. That's why I made this. 16 | 17 | ## Install 18 | 19 | ``` 20 | npm install --save use-mount-effect 21 | ``` 22 | 23 | ## Usage 24 | 25 | Basic usage: 26 | 27 | ```js 28 | import { useMountEffect } from "use-mount-effect"; 29 | 30 | const MyComponent = () => { 31 | useMountEffect(() => { 32 | // Do something only when the component mounts. 33 | }); 34 | return
Blah blah blah
; 35 | }; 36 | ``` 37 | 38 | With cleanup: 39 | 40 | ```js 41 | import { useMountEffect } from "use-mount-effect"; 42 | 43 | const MyComponent = () => { 44 | useMountEffect(() => { 45 | // Do something only when the component mounts. 46 | return () => { 47 | // Do something only when the component unmounts. 48 | }; 49 | }); 50 | return
Blah blah blah
; 51 | }; 52 | ``` 53 | 54 | ## FAQ 55 | 56 | ### Should I use this hook in production mode? 57 | Actually, you don't need to use this hook in production mode, because it behaves just like `useEffect`. You should really fix your effects if you find your app works incorrectly due to the strict mode, because it implies some logic errors or resource leakage. 58 | 59 | **Always use this hook with caution.** 60 | 61 | ### What's the difference between it and `useEffect`? 62 | The cleanup callback is delayed until the next event loop when you use `useMountEffect`, even in production mode. Be sure to verify that it doesn't affect your code. 63 | 64 | ### Do I really need this? 65 | Just one more reminder. `useEffect` doesn't run twice in production mode. And if there is no user-visible behavior difference between running it once and running it twice, you don't need `useMountEffect`. To learn more, check out [this](https://react.dev/learn/synchronizing-with-effects#sending-analytics). 66 | 67 | ## License 68 | 69 | MIT 70 | -------------------------------------------------------------------------------- /__tests__/basic-test.js: -------------------------------------------------------------------------------- 1 | import { render, cleanup, waitFor } from "@testing-library/react"; 2 | 3 | import { useMountEffect } from "../src/index"; 4 | 5 | test("useMountEffect should invoke the callback once on mount", async () => { 6 | const mountCallback = jest.fn(); 7 | const unmountCallback = jest.fn(); 8 | 9 | const TestComponent = () => { 10 | useMountEffect(() => { 11 | mountCallback(); 12 | return () => { 13 | unmountCallback(); 14 | }; 15 | }); 16 | 17 | return null; 18 | }; 19 | 20 | render(); 21 | await waitFor(() => expect(mountCallback).toHaveBeenCalledTimes(1)); 22 | 23 | cleanup(); 24 | await waitFor(() => expect(unmountCallback).toHaveBeenCalledTimes(1)); 25 | 26 | // Ensure our callback was called exactly once. 27 | expect(mountCallback).toHaveBeenCalledTimes(1); 28 | }); 29 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-env", 4 | ["@babel/preset-react", { runtime: "automatic" }], 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-mount-effect", 3 | "version": "1.0.0", 4 | "description": "A React hook triggered only once on mount.", 5 | "main": "./dist/index.js", 6 | "module": "./src/index.js", 7 | "types": "./src/index.d.ts", 8 | "scripts": { 9 | "compile": "swc ./src/index.js -o ./dist/index.js", 10 | "test": "jest", 11 | "prepare": "npm run compile" 12 | }, 13 | "files": [ 14 | "dist/**/*", 15 | "src/**/*" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/unixzii/use-mount-effect.git" 20 | }, 21 | "keywords": [ 22 | "client", 23 | "componentDidMount", 24 | "react", 25 | "react hooks", 26 | "hooks" 27 | ], 28 | "author": "Cyandev ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/unixzii/use-mount-effect/issues" 32 | }, 33 | "homepage": "https://github.com/unixzii/use-mount-effect#readme", 34 | "devDependencies": { 35 | "@babel/preset-env": "^7.23.8", 36 | "@babel/preset-react": "^7.23.3", 37 | "@swc/cli": "^0.1.63", 38 | "@swc/core": "^1.3.102", 39 | "@testing-library/react": "^14.1.2", 40 | "babel-jest": "^29.7.0", 41 | "jest": "^29.7.0", 42 | "jest-environment-jsdom": "^29.7.0", 43 | "react": "^18.2.0", 44 | "react-dom": "^18.2.0" 45 | }, 46 | "peerDependencies": { 47 | "react": ">=16.0.0" 48 | }, 49 | "jest": { 50 | "verbose": true, 51 | "testEnvironment": "jsdom" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | type Destructor = () => void; 2 | type EffectCallback = () => void | Destructor; 3 | type UseMountEffect = (effect: EffectCallback) => void; 4 | 5 | /** 6 | * It works similarly to `useEffect`, but it fires only once after the component is mounted. 7 | * Use this to register some operations that should only perform when the component is mounted, regardless 8 | * of whether strict mode is enabled. 9 | * 10 | * @param effect Imperative function that can return a cleanup function 11 | */ 12 | export const useMountEffect: UseMountEffect; 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | const scheduleCallback = (() => { 4 | if (typeof window === "undefined" || !window.MessageChannel) { 5 | // Fallback implementation in non-browser environments. 6 | return (cb) => { 7 | setTimeout(cb, 0); 8 | }; 9 | } 10 | 11 | const channel = new MessageChannel(); 12 | let head = [null, null]; 13 | let tail = head; 14 | channel.port1.onmessage = () => { 15 | const cb = head[0]; 16 | if (cb) { 17 | head = head[1]; 18 | cb(); 19 | } 20 | }; 21 | return (cb) => { 22 | const nextNode = [null, null]; 23 | tail[0] = cb; 24 | tail[1] = nextNode; 25 | tail = nextNode; 26 | channel.port2.postMessage(0); 27 | }; 28 | })(); 29 | 30 | export function useMountEffect(cb) { 31 | const state = useRef({ 32 | mounted: false, 33 | cleanup: null, 34 | scheduledCleanups: [], 35 | }); 36 | 37 | useEffect(() => { 38 | const invokeCleanup = () => { 39 | const cleanup = state.current.cleanup; 40 | if (!cleanup) { 41 | return; 42 | } 43 | 44 | const cancellationToken = { value: false }; 45 | scheduleCallback(() => { 46 | if (cancellationToken.value) { 47 | return; 48 | } 49 | 50 | cleanup(); 51 | }); 52 | 53 | state.current.scheduledCleanups.push(cancellationToken); 54 | }; 55 | 56 | if (state.current.mounted) { 57 | // Ignore the second invocation in DEV but cancel the 58 | // scheduled cleanups. 59 | state.current.scheduledCleanups.forEach((t) => { 60 | t.value = true; 61 | }); 62 | state.current.scheduledCleanups = []; 63 | return invokeCleanup; 64 | } 65 | 66 | state.current.mounted = true; 67 | state.current.cleanup = cb(); 68 | 69 | return invokeCleanup; 70 | }, []); 71 | } 72 | --------------------------------------------------------------------------------