From 95e776569590f91a99148c2dba60abd72051475c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Tue, 24 Jun 2025 00:58:16 +0200 Subject: [PATCH 01/13] feat: create useAsyncEffekt and useAsyncMemo hooks for handling async operations in React and package setup --- .gitignore | 60 ++++++++++++++++++++++++++++ package-lock.json | 93 +++++++++++++++++++++++++++++++++++++++++++ package.json | 41 +++++++++++++++++++ src/index.ts | 2 + src/useAsyncEffekt.ts | 52 ++++++++++++++++++++++++ src/useAsyncMemo.ts | 45 +++++++++++++++++++++ tsconfig.json | 27 +++++++++++++ 7 files changed, 320 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/useAsyncEffekt.ts create mode 100644 src/useAsyncMemo.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a378fbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build output +dist/ +build/ + +# TypeScript +*.tsbuildinfo + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..30e8c1c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "use-async-effekt", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "use-async-effekt", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..dffea56 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "use-async-effekt", + "version": "1.0.0", + "description": "React hooks for async effects and memoization with proper dependency tracking", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "react", + "hooks", + "async", + "effect", + "memo", + "typescript" + ], + "author": "Dave Gööck", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/davemecha/use-async-effekt.git" + }, + "bugs": { + "url": "https://github.com/davemecha/use-async-effekt/issues" + }, + "homepage": "https://github.com/davemecha/use-async-effekt#readme", + "peerDependencies": { + "react": ">=16.8.0" + }, + "devDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "typescript": "^5.0.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..44ad500 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export { useAsyncEffekt } from './useAsyncEffekt'; +export { useAsyncMemo } from './useAsyncMemo'; diff --git a/src/useAsyncEffekt.ts b/src/useAsyncEffekt.ts new file mode 100644 index 0000000..25c39ed --- /dev/null +++ b/src/useAsyncEffekt.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef, DependencyList } from "react"; + +/** + * A hook for handling async effects with proper dependency tracking. + * The name is intentionally spelled with "k" to work correctly with react-hooks/exhaustive-deps ESLint rule. + * + * @param effect - An async function to execute + * @param deps - Dependency array for the effect + */ +export function useAsyncEffekt( + effect: (isMounted: () => boolean) => Promise void)>, + deps?: DependencyList +): void { + const isMountedRef = useRef(true); + const cleanupRef = useRef<(() => void) | void>(); + + useEffect(() => { + isMountedRef.current = true; + + const executeEffect = async () => { + try { + const cleanup = await effect(() => isMountedRef.current); + if (isMountedRef.current) { + cleanupRef.current = cleanup; + } else if (cleanup) { + // If component unmounted while effect was running, call cleanup immediately + cleanup(); + } + } catch (error) { + if (isMountedRef.current) { + console.error("useAsyncEffekt error:", error); + } + } + }; + + executeEffect(); + + return () => { + isMountedRef.current = false; + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = undefined; + } + }; + }, deps); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); +} diff --git a/src/useAsyncMemo.ts b/src/useAsyncMemo.ts new file mode 100644 index 0000000..fe62f9c --- /dev/null +++ b/src/useAsyncMemo.ts @@ -0,0 +1,45 @@ +import { useState, useEffect, useRef, DependencyList } from "react"; + +/** + * A hook for memoizing async computations with dependency tracking. + * + * @param factory - An async function that returns the memoized value + * @param deps - Dependency array for the memoization + * @returns The memoized value, undefined while loading, or the last successful value on error + */ +export function useAsyncMemo( + factory: (isMounted: () => boolean) => Promise | T, + deps?: DependencyList +): T | undefined { + const [value, setValue] = useState(undefined); + const isMountedRef = useRef(true); + const lastSuccessfulValueRef = useRef(undefined); + + useEffect(() => { + const executeFactory = async () => { + try { + const result = await factory(() => isMountedRef.current); + if (isMountedRef.current) { + setValue(result); + lastSuccessfulValueRef.current = result; + } + } catch (error) { + if (isMountedRef.current) { + console.error("useAsyncMemo error:", error); + // Keep the last successful value on error + setValue(lastSuccessfulValueRef.current); + } + } + }; + + executeFactory(); + }, deps); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + return value; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b3c70e1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "es6"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "jsx": "react-jsx", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} From efb7dd1b09a2512f66a3bbfd30257766c585c624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Tue, 24 Jun 2025 02:15:30 +0200 Subject: [PATCH 02/13] refactor: improve async effect cleanup handling to wait for effect completion --- src/useAsyncEffekt.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/useAsyncEffekt.ts b/src/useAsyncEffekt.ts index 25c39ed..07d3cb5 100644 --- a/src/useAsyncEffekt.ts +++ b/src/useAsyncEffekt.ts @@ -12,20 +12,14 @@ export function useAsyncEffekt( deps?: DependencyList ): void { const isMountedRef = useRef(true); - const cleanupRef = useRef<(() => void) | void>(); useEffect(() => { - isMountedRef.current = true; + let cleanup: (() => void) | void; + let effectPromise: Promise; const executeEffect = async () => { try { - const cleanup = await effect(() => isMountedRef.current); - if (isMountedRef.current) { - cleanupRef.current = cleanup; - } else if (cleanup) { - // If component unmounted while effect was running, call cleanup immediately - cleanup(); - } + cleanup = await effect(() => isMountedRef.current); } catch (error) { if (isMountedRef.current) { console.error("useAsyncEffekt error:", error); @@ -33,14 +27,19 @@ export function useAsyncEffekt( } }; - executeEffect(); + effectPromise = executeEffect(); return () => { - isMountedRef.current = false; - if (cleanupRef.current) { - cleanupRef.current(); - cleanupRef.current = undefined; - } + // Wait for the effect to complete before running cleanup + effectPromise + .then(() => { + if (cleanup) { + cleanup(); + } + }) + .catch(() => { + // Effect already failed, no cleanup needed + }); }; }, deps); From 265334d78b28b11020631108fff8b30b108abd0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Tue, 24 Jun 2025 02:25:42 +0200 Subject: [PATCH 03/13] docs: add comprehensive API docs and ESLint configuration guide --- README.md | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 132 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 9d21494..c86e79d 100644 --- a/README.md +++ b/README.md @@ -14,45 +14,168 @@ npm install use-async-effekt A hook for handling async effects with proper dependency tracking. The name is intentionally spelled with "k" to work correctly with `react-hooks/exhaustive-deps` ESLint rule. +The hook provides an `isMounted` callback to check if the component is still mounted, helping prevent state updates on unmounted components. + +**Features:** + +- Proper cleanup handling - waits for async effects to complete before running cleanup +- Race condition protection when dependencies change rapidly +- Memory leak prevention with mount status checking + ```typescript import { useAsyncEffekt } from "use-async-effekt"; +import { useState } from "react"; function MyComponent() { const [data, setData] = useState(null); - - useAsyncEffekt(async (isMounted: () => boolean) => { - const result = await fetchData(); - if (isMounted()) setData(result); + const [loading, setLoading] = useState(false); + + useAsyncEffekt(async (isMounted) => { + setLoading(true); + + try { + const result = await fetchData(); + if (isMounted()) { + setData(result); + setLoading(false); + } + } catch (error) { + if (isMounted()) { + console.error("Failed to fetch data:", error); + setLoading(false); + } + } + + // Optional cleanup function + return () => { + console.log("Cleaning up effect"); + }; }, []); + if (loading) return
Loading...
; return
{data}
; } ``` ### `useAsyncMemo` -A hook for memoizing async computations with dependency tracking. +A hook for memoizing async computations with dependency tracking. Returns `undefined` while the async computation is in progress. + +**Features:** + +- Automatic memoization based on dependencies +- Preserves last successful value on error +- Mount status checking to prevent memory leaks ```typescript import { useAsyncMemo } from "use-async-effekt"; +import { useState } from "react"; -function MyComponent({ userId }) { +function UserProfile({ userId }) { const userData = useAsyncMemo( - async (isMounted: () => boolean) => { - return await fetchUser(userId); + async (isMounted) => { + const user = await fetchUser(userId); + + // You can check if component is still mounted before expensive operations + if (!isMounted()) return null; + + const additionalData = await fetchUserDetails(userId); + + return { + ...user, + ...additionalData, + }; }, [userId] ); - return
{userData?.name}
; + // userData will be undefined while loading, then contain the result + return ( +
+ {userData ? ( +
+

{userData.name}

+

{userData.email}

+
+ ) : ( +
Loading user...
+ )} +
+ ); } ``` +## ESLint Configuration + +To enable dependency checking for these hooks with the `react-hooks/exhaustive-deps` ESLint rule, add the following configuration to your `.eslintrc.js` or ESLint configuration file: + +```javascript +module.exports = { + // ... other ESLint configuration + rules: { + // ... other rules + "react-hooks/exhaustive-deps": [ + "warn", + { + additionalHooks: "(useAsyncEffekt|useAsyncMemo)", + }, + ], + }, +}; +``` + +Or if you're using `.eslintrc.json`: + +```json +{ + "rules": { + "react-hooks/exhaustive-deps": [ + "warn", + { + "additionalHooks": "(useAsyncEffekt|useAsyncMemo)" + } + ] + } +} +``` + +This configuration tells ESLint to treat `useAsyncEffekt` and `useAsyncMemo` the same way as built-in React hooks like `useEffect` and `useMemo`, ensuring that: + +- Missing dependencies are flagged as warnings +- Unnecessary dependencies are detected +- Dependency arrays are properly validated + +**Note:** The intentional spelling of `useAsyncEffekt` with "k" ensures it matches the regex pattern that ESLint uses to identify effect-like hooks. + +## API Reference + +### `useAsyncEffekt(effect, deps?)` + +**Parameters:** + +- `effect: (isMounted: () => boolean) => Promise void)>` - Async function to execute. Receives an `isMounted` callback and can optionally return a cleanup function. +- `deps?: DependencyList` - Optional dependency array (same as `useEffect`) + +**Returns:** `void` + +### `useAsyncMemo(factory, deps?)` + +**Parameters:** + +- `factory: (isMounted: () => boolean) => Promise | T` - Async function that returns the memoized value. Receives an `isMounted` callback. +- `deps?: DependencyList` - Optional dependency array (same as `useMemo`) + +**Returns:** `T | undefined` - The memoized value, or `undefined` while loading + ## Features - ✅ Full TypeScript support - ✅ Proper dependency tracking - ✅ Compatible with `react-hooks/exhaustive-deps` +- ✅ Race condition protection +- ✅ Memory leak prevention +- ✅ Cleanup function support +- ✅ Error handling with value preservation - ✅ Lightweight and performant ## License From 9ecea10a1396ce9fcdf3787640dbd388962559bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Wed, 25 Jun 2025 18:04:08 +0200 Subject: [PATCH 04/13] chore: update package files and add .npmignore for cleaner npm package --- .npmignore | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 4 +++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 .npmignore diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..90fed56 --- /dev/null +++ b/.npmignore @@ -0,0 +1,57 @@ +# Source files +src/ +tsconfig.json + +# Development files +.git/ +.gitignore +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Test files +test/ +tests/ +__tests__/ +*.test.js +*.test.ts +*.spec.js +*.spec.ts + +# Coverage +coverage/ +.nyc_output + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/package.json b/package.json index dffea56..0f3b451 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ - "dist" + "dist", + "README.md", + "LICENSE" ], "scripts": { "build": "tsc", From 8e65ac96d03a8932d01c83ec596ea93c217e3011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Wed, 25 Jun 2025 21:17:46 +0200 Subject: [PATCH 05/13] feat: add waitForLastEffect to coordinate async effect cleanup sequencing --- src/useAsyncEffekt.ts | 54 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/useAsyncEffekt.ts b/src/useAsyncEffekt.ts index 07d3cb5..270bb8c 100644 --- a/src/useAsyncEffekt.ts +++ b/src/useAsyncEffekt.ts @@ -8,18 +8,37 @@ import { useEffect, useRef, DependencyList } from "react"; * @param deps - Dependency array for the effect */ export function useAsyncEffekt( - effect: (isMounted: () => boolean) => Promise void)>, + effect: ({ + isMounted, + waitForLastEffect, + }: { + isMounted: () => boolean; + waitForLastEffect: () => Promise; + }) => Promise void | Promise)>, deps?: DependencyList ): void { const isMountedRef = useRef(true); + // Track the promise chain of all previous effects and their cleanups + const lastEffectChainRef = useRef>(Promise.resolve()); useEffect(() => { - let cleanup: (() => void) | void; - let effectPromise: Promise; + let cleanup: (() => void | Promise) | void; + let cleanupResolver: (() => void) | null = null; + + // Capture the current chain to wait for + const previousEffectChain = lastEffectChainRef.current; + + // Create a promise that resolves when this effect's cleanup is complete + const cleanupPromise = new Promise((resolve) => { + cleanupResolver = resolve; + }); const executeEffect = async () => { try { - cleanup = await effect(() => isMountedRef.current); + cleanup = await effect({ + isMounted: () => isMountedRef.current, + waitForLastEffect: () => previousEffectChain, + }); } catch (error) { if (isMountedRef.current) { console.error("useAsyncEffekt error:", error); @@ -27,18 +46,33 @@ export function useAsyncEffekt( } }; - effectPromise = executeEffect(); + // Create the current effect promise + const currentEffectPromise = executeEffect(); + + // Update the chain to include both current effect and its future cleanup + lastEffectChainRef.current = currentEffectPromise.then( + () => cleanupPromise + ); return () => { - // Wait for the effect to complete before running cleanup - effectPromise - .then(() => { - if (cleanup) { - cleanup(); + // Trigger cleanup and resolve the cleanup promise + currentEffectPromise + .then(async () => { + if (!cleanup) return; + try { + await cleanup(); + } catch (error) { + console.error("useAsyncEffekt cleanup error:", error); } }) .catch(() => { // Effect already failed, no cleanup needed + }) + .then(() => { + // Resolve the cleanup promise to signal completion + if (cleanupResolver) { + cleanupResolver(); + } }); }; }, deps); From 2cbcc786cdefa09948ac006aa52d3084c4f59379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 01:09:52 +0200 Subject: [PATCH 06/13] refactor: rename waitForLastEffect param to waitForPrevious for clarity --- src/useAsyncEffekt.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/useAsyncEffekt.ts b/src/useAsyncEffekt.ts index 270bb8c..e0dc3ea 100644 --- a/src/useAsyncEffekt.ts +++ b/src/useAsyncEffekt.ts @@ -10,10 +10,10 @@ import { useEffect, useRef, DependencyList } from "react"; export function useAsyncEffekt( effect: ({ isMounted, - waitForLastEffect, + waitForPrevious, }: { isMounted: () => boolean; - waitForLastEffect: () => Promise; + waitForPrevious: () => Promise; }) => Promise void | Promise)>, deps?: DependencyList ): void { @@ -37,7 +37,7 @@ export function useAsyncEffekt( try { cleanup = await effect({ isMounted: () => isMountedRef.current, - waitForLastEffect: () => previousEffectChain, + waitForPrevious: () => previousEffectChain, }); } catch (error) { if (isMountedRef.current) { From 687cab58699990694368bb748e120e9ad296112f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 01:34:09 +0200 Subject: [PATCH 07/13] Add proper documentation for useAsyncEffect --- README.md | 201 +++++++++++++++++++++++++++++++++++++++--- src/useAsyncEffekt.ts | 65 +++++++++++++- 2 files changed, 253 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c86e79d..ac0c33e 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,23 @@ npm install use-async-effekt ### `useAsyncEffekt` -A hook for handling async effects with proper dependency tracking. The name is intentionally spelled with "k" to work correctly with `react-hooks/exhaustive-deps` ESLint rule. +A hook for handling async effects with proper dependency tracking and cleanup management. The name is intentionally spelled with "k" to work correctly with `react-hooks/exhaustive-deps` ESLint rule. -The hook provides an `isMounted` callback to check if the component is still mounted, helping prevent state updates on unmounted components. +The hook provides: + +- An `isMounted` callback to check if the component is still mounted +- A `waitForPrevious` function to wait for previous effects and their cleanup to complete +- Support for both synchronous and asynchronous cleanup functions **Features:** - Proper cleanup handling - waits for async effects to complete before running cleanup - Race condition protection when dependencies change rapidly - Memory leak prevention with mount status checking +- Sequential effect execution when needed +- Support for both sync and async cleanup functions + +#### Basic Usage (Without Waiting) ```typescript import { useAsyncEffekt } from "use-async-effekt"; @@ -30,7 +38,7 @@ function MyComponent() { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); - useAsyncEffekt(async (isMounted) => { + useAsyncEffekt(async ({ isMounted }) => { setLoading(true); try { @@ -45,11 +53,6 @@ function MyComponent() { setLoading(false); } } - - // Optional cleanup function - return () => { - console.log("Cleaning up effect"); - }; }, []); if (loading) return
Loading...
; @@ -57,6 +60,184 @@ function MyComponent() { } ``` +#### Usage with Sequential Effect Execution + +When you need to ensure that previous effects complete before starting new ones: + +```typescript +function SearchComponent({ query }) { + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + + useAsyncEffekt( + async ({ isMounted, waitForPrevious }) => { + // Wait for any previous search to complete and clean up + await waitForPrevious(); + + if (!query) return; + + setLoading(true); + + try { + const searchResults = await searchAPI(query); + if (isMounted()) { + setResults(searchResults); + setLoading(false); + } + } catch (error) { + if (isMounted()) { + console.error("Search failed:", error); + setLoading(false); + } + } + }, + [query] + ); + + return ( +
+ {loading &&
Searching...
} + {results.map((result) => ( +
{result.title}
+ ))} +
+ ); +} +``` + +#### Usage with Synchronous Cleanup + +```typescript +function SubscriptionComponent({ topic }) { + const [messages, setMessages] = useState([]); + + useAsyncEffekt( + async ({ isMounted }) => { + const subscription = await createSubscription(topic); + + subscription.onMessage((message) => { + if (isMounted()) { + setMessages((prev) => [...prev, message]); + } + }); + + // Return synchronous cleanup function + return () => { + subscription.unsubscribe(); + console.log("Subscription cleaned up"); + }; + }, + [topic] + ); + + return ( +
+ {messages.map((msg, i) => ( +
{msg}
+ ))} +
+ ); +} +``` + +#### Usage with Asynchronous Cleanup + +```typescript +function ConnectionComponent({ endpoint }) { + const [status, setStatus] = useState("disconnected"); + + useAsyncEffekt( + async ({ isMounted }) => { + const connection = await establishConnection(endpoint); + + if (isMounted()) { + setStatus("connected"); + } + + // Return asynchronous cleanup function + return async () => { + if (isMounted()) { + setStatus("disconnecting"); + await connection.gracefulShutdown(); + setStatus("disconnected"); + console.log("Connection closed gracefully"); + } + }; + }, + [endpoint] + ); + + return
Status: {status}
; +} +``` + +#### Complex Usage: Sequential Effects with Async Cleanup + +```typescript +function ResourceManager({ resourceId }) { + const [resource, setResource] = useState(null); + const [status, setStatus] = useState("idle"); + + useAsyncEffekt( + async ({ isMounted, waitForPrevious }) => { + // Ensure previous resource is fully cleaned up before acquiring new one + await waitForPrevious(); + + if (!resourceId) return; + + setStatus("acquiring"); + + try { + const newResource = await acquireResource(resourceId); + + if (isMounted()) { + setResource(newResource); + setStatus("ready"); + } + + // Return async cleanup to properly release the resource + return async () => { + setStatus("releasing"); + await newResource.release(); + setStatus("idle"); + console.log(`Resource ${resourceId} released`); + }; + } catch (error) { + if (isMounted()) { + setStatus("error"); + console.error("Failed to acquire resource:", error); + } + } + }, + [resourceId] + ); + + return ( +
+
Status: {status}
+ {resource &&
Resource ID: {resource.id}
} +
+ ); +} +``` + +#### When to Use `waitForPrevious` + +Use `waitForPrevious()` when: + +- You need to ensure previous effects complete before starting new ones +- You're managing exclusive resources (database connections, file handles, etc.) +- You want to prevent race conditions in sequential operations +- You need to guarantee cleanup order + +Don't use `waitForPrevious()` when: + +- Effects can run independently and concurrently +- You want maximum performance and don't need sequencing +- Effects are simple and don't have interdependencies + +In most cases, you should not use `waitForPrevious()` to keep your application responsive. It is always a trade-off between responsiveness and slower sequential execution. + ### `useAsyncMemo` A hook for memoizing async computations with dependency tracking. Returns `undefined` while the async computation is in progress. @@ -153,7 +334,7 @@ This configuration tells ESLint to treat `useAsyncEffekt` and `useAsyncMemo` the **Parameters:** -- `effect: (isMounted: () => boolean) => Promise void)>` - Async function to execute. Receives an `isMounted` callback and can optionally return a cleanup function. +- `effect: ({ isMounted, waitForPrevious }: { isMounted: () => boolean, waitForPrevious: () => Promise }) => Promise void | Promise)>` - Async function to execute. Receives an `isMounted` callback and a `waitForPrevious` function, and can optionally return a cleanup function. - `deps?: DependencyList` - Optional dependency array (same as `useEffect`) **Returns:** `void` @@ -180,4 +361,4 @@ This configuration tells ESLint to treat `useAsyncEffekt` and `useAsyncMemo` the ## License -MIT © Dave Gööck +MIT Dave Gööck diff --git a/src/useAsyncEffekt.ts b/src/useAsyncEffekt.ts index e0dc3ea..83582ac 100644 --- a/src/useAsyncEffekt.ts +++ b/src/useAsyncEffekt.ts @@ -1,11 +1,70 @@ import { useEffect, useRef, DependencyList } from "react"; /** - * A hook for handling async effects with proper dependency tracking. + * A hook for handling async effects with proper dependency tracking and cleanup management. * The name is intentionally spelled with "k" to work correctly with react-hooks/exhaustive-deps ESLint rule. * - * @param effect - An async function to execute - * @param deps - Dependency array for the effect + * @param effect - An async function to execute. Receives an object with: + * - `isMounted`: Function to check if the component is still mounted + * - `waitForPrevious`: Function that returns a Promise to wait for the previous effect and its cleanup to complete + * + * The effect function can optionally return a cleanup function that can be either synchronous or asynchronous. + * + * @param deps - Dependency array for the effect (same as useEffect) + * + * @example + * // Basic usage without waiting for previous effects + * useAsyncEffekt(async ({ isMounted }) => { + * const data = await fetchData(); + * if (isMounted()) { + * setData(data); + * } + * }, []); + * + * @example + * // Usage with waiting for previous effect to complete + * useAsyncEffekt(async ({ isMounted, waitForPrevious }) => { + * await waitForPrevious(); // Wait for previous effect and cleanup + * const data = await fetchData(); + * if (isMounted()) { + * setData(data); + * } + * }, [dependency]); + * + * @example + * // Usage with synchronous cleanup + * useAsyncEffekt(async ({ isMounted }) => { + * const subscription = await createSubscription(); + * + * return () => { + * subscription.unsubscribe(); // Sync cleanup + * }; + * }, []); + * + * @example + * // Usage with asynchronous cleanup + * useAsyncEffekt(async ({ isMounted }) => { + * const connection = await establishConnection(); + * + * return async () => { + * await connection.close(); // Async cleanup + * }; + * }, []); + * + * @example + * // Complex usage with waiting and async cleanup + * useAsyncEffekt(async ({ isMounted, waitForPrevious }) => { + * await waitForPrevious(); // Ensure previous effect is fully cleaned up + * + * const resource = await acquireResource(); + * if (isMounted()) { + * setResource(resource); + * } + * + * return async () => { + * await resource.cleanup(); // Async cleanup + * }; + * }, [resourceId]); */ export function useAsyncEffekt( effect: ({ From 758f8a5944f62806788c9ac9a66e4bcfc472c680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 02:01:00 +0200 Subject: [PATCH 08/13] adding cancellation support for race conditions in useAsyncMemo Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/useAsyncMemo.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/useAsyncMemo.ts b/src/useAsyncMemo.ts index fe62f9c..e222093 100644 --- a/src/useAsyncMemo.ts +++ b/src/useAsyncMemo.ts @@ -16,15 +16,16 @@ export function useAsyncMemo( const lastSuccessfulValueRef = useRef(undefined); useEffect(() => { + let cancelled = false; const executeFactory = async () => { try { const result = await factory(() => isMountedRef.current); - if (isMountedRef.current) { + if (isMountedRef.current && !cancelled) { setValue(result); lastSuccessfulValueRef.current = result; } } catch (error) { - if (isMountedRef.current) { + if (isMountedRef.current && !cancelled) { console.error("useAsyncMemo error:", error); // Keep the last successful value on error setValue(lastSuccessfulValueRef.current); @@ -33,6 +34,10 @@ export function useAsyncMemo( }; executeFactory(); + + return () => { + cancelled = true; + }; }, deps); useEffect(() => { From 4856d32edfff6d74aa094fb14596dd42fe5d6ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 02:18:41 +0200 Subject: [PATCH 09/13] fix:avoid timing issues managing component mounted state in useAsyncMemo hook --- src/useAsyncMemo.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/useAsyncMemo.ts b/src/useAsyncMemo.ts index e222093..8630250 100644 --- a/src/useAsyncMemo.ts +++ b/src/useAsyncMemo.ts @@ -16,7 +16,9 @@ export function useAsyncMemo( const lastSuccessfulValueRef = useRef(undefined); useEffect(() => { + isMountedRef.current = true; let cancelled = false; + const executeFactory = async () => { try { const result = await factory(() => isMountedRef.current); @@ -36,15 +38,10 @@ export function useAsyncMemo( executeFactory(); return () => { + isMountedRef.current = false; cancelled = true; }; }, deps); - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); - return value; } From b8df250cf6db1c1ef81de9ae98585f698e131562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 02:21:17 +0200 Subject: [PATCH 10/13] fix:avoid timing issues managing component mounted state in useAsyncEffekt hook --- src/useAsyncEffekt.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/useAsyncEffekt.ts b/src/useAsyncEffekt.ts index 83582ac..baf6b2b 100644 --- a/src/useAsyncEffekt.ts +++ b/src/useAsyncEffekt.ts @@ -81,6 +81,7 @@ export function useAsyncEffekt( const lastEffectChainRef = useRef>(Promise.resolve()); useEffect(() => { + isMountedRef.current = true; let cleanup: (() => void | Promise) | void; let cleanupResolver: (() => void) | null = null; @@ -114,6 +115,7 @@ export function useAsyncEffekt( ); return () => { + isMountedRef.current = false; // Trigger cleanup and resolve the cleanup promise currentEffectPromise .then(async () => { @@ -135,10 +137,4 @@ export function useAsyncEffekt( }); }; }, deps); - - useEffect(() => { - return () => { - isMountedRef.current = false; - }; - }, []); } From ec5df00f824b587c8472298930264be24d7d789f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 02:36:24 +0200 Subject: [PATCH 11/13] improve tsconfig.json --- tsconfig.json | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index b3c70e1..ff86ca0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "es6"], - "allowJs": true, + "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, @@ -11,17 +11,14 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, + "module": "ES2015", "noEmit": false, "jsx": "react-jsx", "declaration": true, + "declarationMap": true, "outDir": "./dist", "rootDir": "./src" }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] } From b7088344094f577db7e415a26d8ed1a85c4173cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 02:36:41 +0200 Subject: [PATCH 12/13] cap peerDependency --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0f3b451..d5fef24 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "homepage": "https://github.com/davemecha/use-async-effekt#readme", "peerDependencies": { - "react": ">=16.8.0" + "react": ">=16.8.0 <20" }, "devDependencies": { "@types/react": "^18.0.0", From 65d79dae85850012ee4e54ae2d35fa168550e578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dave=20G=C3=B6=C3=B6ck?= Date: Thu, 26 Jun 2025 02:36:56 +0200 Subject: [PATCH 13/13] remove redundant npmignore file --- .npmignore | 57 ------------------------------------------------------ 1 file changed, 57 deletions(-) delete mode 100644 .npmignore diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 90fed56..0000000 --- a/.npmignore +++ /dev/null @@ -1,57 +0,0 @@ -# Source files -src/ -tsconfig.json - -# Development files -.git/ -.gitignore -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# IDE files -.vscode/ -.idea/ -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Test files -test/ -tests/ -__tests__/ -*.test.js -*.test.ts -*.spec.js -*.spec.ts - -# Coverage -coverage/ -.nyc_output - -# Logs -logs -*.log - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Environment files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local