React hooks for async effects and memoization with proper dependency tracking and linting support.
Note: Tests are vibe coded. Specific tests are added when bugs are reported.
npm install use-async-effekt-hooks
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
import { useAsyncEffekt } from "use-async-effekt-hooks";
import { useState } from "react";
function MyComponent() {
const [data, setData] = useState(null);
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);
}
}
}, []);
if (loading) return <div>Loading...</div>;
return <div>{data}</div>;
}
When you need to ensure that previous effects complete before starting new ones:
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 (
<div>
{loading && <div>Searching...</div>}
{results.map((result) => (
<div key={result.id}>{result.title}</div>
))}
</div>
);
}
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 (
<div>
{messages.map((msg, i) => (
<div key={i}>{msg}</div>
))}
</div>
);
}
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 <div>Status: {status}</div>;
}
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 (
<div>
<div>Status: {status}</div>
{resource && <div>Resource ID: {resource.id}</div>}
</div>
);
}
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.
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
import { useAsyncMemo } from "use-async-effekt";
import { useState } from "react";
function UserProfile({ userId }) {
const userData = useAsyncMemo(
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]
);
// userData will be undefined while loading, then contain the result
return (
<div>
{userData ? (
<div>
<h1>{userData.name}</h1>
<p>{userData.email}</p>
</div>
) : (
<div>Loading user...</div>
)}
</div>
);
}
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:
module.exports = {
// ... other ESLint configuration
rules: {
// ... other rules
"react-hooks/exhaustive-deps": [
"warn",
{
additionalHooks: "(useAsyncEffekt|useAsyncMemo|useAsyncMemoSuspense)",
},
],
},
};
Or if you're using .eslintrc.json
:
{
"rules": {
"react-hooks/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useAsyncEffekt|useAsyncMemo|useAsyncMemoSuspense)"
}
]
}
}
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.
Parameters:
effect: ({ isMounted, waitForPrevious }: { isMounted: () => boolean, waitForPrevious: () => Promise<void> }) => Promise<void | (() => void | Promise<void>)>
- Async function to execute. Receives anisMounted
callback and awaitForPrevious
function, and can optionally return a cleanup function.deps?: DependencyList
- Optional dependency array (same asuseEffect
)
Returns: void
Parameters:
factory: (isMounted: () => boolean) => Promise<T> | T
- Async function that returns the memoized value. Receives anisMounted
callback.deps?: DependencyList
- Optional dependency array (same asuseMemo
)
Returns: T | undefined
- The memoized value, or undefined
while loading
Parameters:
factory: () => Promise<T> | T
- The async function to execute.deps?: DependencyList
- Optional dependency array (same asuseMemo
).options?: { scope?: string }
- Optional options object.scope?: string
- An optional scope to isolate the cache. This is useful when you have multiple instances of the hook with the same factory and dependencies but you want to keep their caches separate.
Returns: T
- The memoized value. It suspends the component while the async operation is in progress.
Important Notes:
- SSR Environments (e.g., Next.js): In a server-side rendering environment, this hook will always return
undefined
on the server. The component will suspend on the client during hydration (not on initial render on the server). This means the suspense fallback will be displayed on hydration, and nothing will be displayed on the server-side render. - Client Component: This hook must be used within a "client component" (e.g., in Next.js, the file must have the
"use client";
directive at the top). - Experimental: This hook is experimental and its API might change in future versions.
Example:
import { Suspense } from "react";
import { useAsyncMemoSuspense } from "use-async-effekt-hooks";
function UserProfile({ userId }) {
const user = useAsyncMemoSuspense(async () => {
const response = await fetch(`https://api.example.com/users/${userId}`);
return response.json();
}, [userId]);
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId="1" />
</Suspense>
);
}
- ✅ 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
MIT Dave Gööck