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/README.md b/README.md index 9d21494..ac0c33e 100644 --- a/README.md +++ b/README.md @@ -12,49 +12,353 @@ 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 +- 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"; +import { useState } from "react"; function MyComponent() { const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + useAsyncEffekt(async ({ isMounted }) => { + setLoading(true); - useAsyncEffekt(async (isMounted: () => boolean) => { - const result = await fetchData(); - if (isMounted()) setData(result); + try { + const result = await fetchData(); + if (isMounted()) { + setData(result); + setLoading(false); + } + } catch (error) { + if (isMounted()) { + console.error("Failed to fetch data:", error); + setLoading(false); + } + } }, []); + if (loading) return
Loading...
; return
{data}
; } ``` +#### 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. +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, 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` + +### `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 -MIT © Dave Gööck +MIT Dave Gööck 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..d5fef24 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "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", + "README.md", + "LICENSE" + ], + "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 <20" + }, + "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..baf6b2b --- /dev/null +++ b/src/useAsyncEffekt.ts @@ -0,0 +1,140 @@ +import { useEffect, useRef, DependencyList } from "react"; + +/** + * 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. 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: ({ + isMounted, + waitForPrevious, + }: { + isMounted: () => boolean; + waitForPrevious: () => 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(() => { + isMountedRef.current = true; + 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({ + isMounted: () => isMountedRef.current, + waitForPrevious: () => previousEffectChain, + }); + } catch (error) { + if (isMountedRef.current) { + console.error("useAsyncEffekt error:", error); + } + } + }; + + // 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 () => { + isMountedRef.current = false; + // 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); +} diff --git a/src/useAsyncMemo.ts b/src/useAsyncMemo.ts new file mode 100644 index 0000000..8630250 --- /dev/null +++ b/src/useAsyncMemo.ts @@ -0,0 +1,47 @@ +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(() => { + isMountedRef.current = true; + let cancelled = false; + + const executeFactory = async () => { + try { + const result = await factory(() => isMountedRef.current); + if (isMountedRef.current && !cancelled) { + setValue(result); + lastSuccessfulValueRef.current = result; + } + } catch (error) { + if (isMountedRef.current && !cancelled) { + console.error("useAsyncMemo error:", error); + // Keep the last successful value on error + setValue(lastSuccessfulValueRef.current); + } + } + }; + + executeFactory(); + + return () => { + isMountedRef.current = false; + cancelled = true; + }; + }, deps); + + return value; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff86ca0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "es6"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "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"] +}