Skip to content

davemecha/use-async-effekt

Repository files navigation

use-async-effekt-hooks

React hooks for async effects and memoization with proper dependency tracking and linting support.

CI npm codecov npm downloads MIT

bundle size Types react 16-19 GitHub stars issues

Note: Tests are vibe coded. Specific tests are added when bugs are reported.

Installation

npm install use-async-effekt-hooks

Hooks

useAsyncEffekt

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)

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>;
}

Usage with Sequential Effect Execution

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>
  );
}

Usage with Synchronous Cleanup

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>
  );
}

Usage with Asynchronous Cleanup

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>;
}

Complex Usage: Sequential Effects with Async Cleanup

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>
  );
}

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.

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>
  );
}

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:

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.

API Reference

useAsyncEffekt(effect, deps?)

Parameters:

  • effect: ({ isMounted, waitForPrevious }: { isMounted: () => boolean, waitForPrevious: () => Promise<void> }) => Promise<void | (() => void | Promise<void>)> - 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> | 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

useAsyncMemoSuspense(factory, deps?, options?)

Parameters:

  • factory: () => Promise<T> | T - The async function to execute.
  • deps?: DependencyList - Optional dependency array (same as useMemo).
  • 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>
  );
}

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

About

Async API compatible React effects and memos with linting support

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •