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"]
+}