Writing Asynchronous Hooks with useAsync

Loading

Handling asynchronous operations like fetching data, submitting forms, or dealing with side effects in React often involves managing state for loading, success, and error scenarios. Writing a reusable custom hook to handle asynchronous behavior can simplify your components and improve the maintainability of your code.

In this article, we will create a custom hook called useAsync to abstract the logic of handling asynchronous operations, including loading, success, and error states. This hook can be used in various scenarios, such as fetching data from an API or handling any other async operations.

1. What is useAsync?

useAsync is a custom React hook that simplifies the process of handling asynchronous operations like fetching data or performing other side effects that require async actions. It provides states such as loading, data, and error, which makes it easy to manage different states of an asynchronous operation.

2. Why Use a Custom Hook for Asynchronous Operations?

Creating a custom hook like useAsync for asynchronous operations helps you:

  • Reusability: Use the same logic across different components.
  • Separation of Concerns: Keep your components clean by separating async logic into a custom hook.
  • State Management: Handle loading, success, and error states centrally.

3. Basic Structure of useAsync

We’ll create a useAsync hook that manages the state of an asynchronous operation. It will return loading, data, and error states, along with a function to trigger the async operation.

4. Creating the useAsync Hook

Here’s how we can create a basic useAsync hook:

import { useState, useCallback } from 'react';

const useAsync = (asyncFunction, deps = []) => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const result = await asyncFunction();
      setData(result);
    } catch (err) {
      setError(err);
    } finally {
      setLoading(false);
    }
  }, [asyncFunction, ...deps]); // Re-run when asyncFunction or dependencies change

  return {
    loading,
    data,
    error,
    execute
  };
};

export default useAsync;

5. How useAsync Works

  • State Management: The hook manages three states: loading, data, and error.
    • loading: Tracks whether the async operation is in progress.
    • data: Stores the data returned by the async operation (if successful).
    • error: Stores the error (if any) that occurred during the async operation.
  • execute function: This function triggers the asynchronous operation when invoked. It updates the state accordingly:
    • Sets loading to true when the operation starts.
    • Sets data or error when the operation completes or fails.
    • Sets loading to false once the operation is complete.

6. Using useAsync in a Component

Let’s now see how to use the useAsync hook in a component to perform an asynchronous operation, like fetching data from an API.

Example: Fetching Data with useAsync

import React, { useEffect } from 'react';
import useAsync from './useAsync';

const fetchData = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  if (!response.ok) throw new Error('Failed to fetch data');
  return response.json();
};

const DataFetchingComponent = () => {
  const { loading, data, error, execute } = useAsync(fetchData);

  useEffect(() => {
    execute(); // Trigger the async operation on component mount
  }, [execute]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return <div>No data available</div>;

  return (
    <div>
      <h3>Posts</h3>
      <ul>
        {data.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default DataFetchingComponent;

7. How it Works in the Component

  • Triggering the Async Operation: In the component, we use useEffect to invoke the execute function when the component is mounted. This starts the async operation (fetching the data).
  • Displaying States: We use the loading, data, and error states returned by the useAsync hook to conditionally render different UI states:
    • While loading, show a loading message.
    • If there’s an error, show the error message.
    • If data is successfully fetched, display the data.

8. Handling Re-fetching or Manual Triggering

The execute function provided by useAsync allows you to manually trigger the asynchronous operation when needed, making the hook reusable in multiple scenarios, such as for refetching data when a user performs some action.

Example: Manually Triggering the Async Operation

const DataFetchingComponent = () => {
  const { loading, data, error, execute } = useAsync(fetchData);

  return (
    <div>
      <button onClick={execute} disabled={loading}>
        {loading ? 'Loading...' : 'Fetch Data'}
      </button>

      {error && <div>Error: {error.message}</div>}
      {data && (
        <ul>
          {data.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

In this example, the user can manually trigger the data fetch by clicking a button. While the fetch operation is in progress, the button is disabled.

9. Optimizing the Hook for Caching

To avoid making repeated requests for the same data, you can enhance the useAsync hook by adding caching logic. For example, you can store the data in a useRef or useState and only fetch if the data isn’t already available.

10. Error Handling and Retry Mechanism

For complex applications, you might want to handle retries when an error occurs during an async operation. You can enhance the useAsync hook with retry logic or use third-party libraries like axios-retry to add retry functionality.

Example: Adding Retry Logic

const useAsync = (asyncFunction, deps = [], retries = 3) => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback(async () => {
    setLoading(true);
    setError(null);
    let attempts = 0;

    while (attempts < retries) {
      try {
        const result = await asyncFunction();
        setData(result);
        break; // Success, exit the loop
      } catch (err) {
        attempts += 1;
        if (attempts === retries) {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }
  }, [asyncFunction, retries, ...deps]);

  return { loading, data, error, execute };
};

Leave a Reply

Your email address will not be published. Required fields are marked *