Implementing and Utilizing the useAsync Hook: A Step-by-Step Guide

5 min read

📚 Quick Review: This practical application is built upon a fundamental programming concept. Review the Theory Lesson here first.


Deconstructing the useAsync Hook

Let’s dive into the code for our useAsync custom hook and understand each part. This hook provides a clean interface for managing asynchronous operations within your React components.

import { useState, useEffect, useCallback } from 'react';

function useAsync(asyncFunction, immediate = true) {
  const [status, setStatus] = useState('idle');
  const [value, setValue] = useState(null);
  const [error, setError] = useState(null);

  const execute = useCallback(() => {
    setStatus('pending'); setValue(null); setError(null);
    return asyncFunction().then(response => { setValue(response); setStatus('success'); })
      .catch(error => { setError(error); setStatus('error'); });
  }, [asyncFunction]);

  useEffect(() => { if (immediate) execute(); }, [execute, immediate]);

  return { execute, status, value, error };
}

Line-by-Line Explanation:

import { useState, useEffect, useCallback } from 'react';

  • This line imports the necessary built-in React hooks:
  • useState: For managing the internal state of our asynchronous operation (status, value, error).
  • useEffect: For performing side effects, specifically to trigger the async function immediately if required.
  • useCallback: For memoizing the execute function, preventing unnecessary re-creations and optimizing performance.

function useAsync(asyncFunction, immediate = true) {

  • This defines our custom hook. It takes two parameters:
  • asyncFunction: A function that returns a Promise. This is the actual asynchronous task we want to perform (e.g., an API call).
  • immediate = true: An optional boolean flag. If true (default), the asyncFunction will be executed immediately when the component mounts. If false, you’ll need to call execute manually.

const [status, setStatus] = useState('idle');

  • Initializes a state variable status with 'idle'. This will track the current state of the async operation ('idle', 'pending', 'success', 'error').

const [value, setValue] = useState(null);

  • Initializes value to null. This state variable will hold the successful result of the asyncFunction once it resolves.

const [error, setError] = useState(null);

  • Initializes error to null. This state variable will hold any error object if the asyncFunction rejects.

const execute = useCallback(() => { ... }, [asyncFunction]);

  • This is the core function that actually runs the asyncFunction.
  • It’s wrapped in useCallback to ensure that the execute function itself is memoized. This means it will only be re-created if its dependencies change.
  • setStatus('pending'); setValue(null); setError(null);: Before executing, it resets the state to 'pending' and clears any previous value or error.
  • return asyncFunction().then(...) .catch(...);: It calls the provided asyncFunction.
  • If the Promise resolves (.then()), it updates value with the response and sets status to 'success'.
  • If the Promise rejects (.catch()), it updates error with the error object and sets status to 'error'.
  • The [asyncFunction] in the dependency array means that execute will only be re-created if the asyncFunction itself changes.

useEffect(() => { if (immediate) execute(); }, [execute, immediate]);

  • This useEffect hook handles the immediate execution logic.
  • It runs once after the initial render (and whenever execute or immediate changes).
  • If immediate is true, it calls the execute function, triggering the asynchronous operation.
  • The dependency array [execute, immediate] is crucial. It ensures that if the execute function (due to asyncFunction changing) or the immediate flag changes, the effect re-runs.

return { execute, status, value, error };

  • Finally, the hook returns an object containing the execute function and the current status, value, and error states. This is the public API that components will use.

How to Use useAsync in Your Components

Using the useAsync hook in a React component is straightforward. Let’s imagine we want to fetch a list of users from a mock API.

import React from 'react';
import { useAsync } from './useAsync'; // Assuming useAsync is in useAsync.js

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

function UserList() {
  const { execute, status, value: users, error } = useAsync(fetchUsers, true);

  if (status === 'pending') {
    return <div>Loading users...</div>;
  }

  if (status === 'error') {
    return <div>Error: {error ? error.message : 'Unknown error'}</div>;
  }

  if (status === 'success' && users) {
    return (
      <div>
        <h2>User List</h2>
        <ul>
          {users.map(user => (
            <li key={user.id}>{user.name} ({user.email})</li>
          ))}
        </ul>
        <button onClick={execute}>Refresh Users</button>
      </div>
    );
  }

  return <div>No users loaded.</div>; // Initial 'idle' state or if users is null
}

export default UserList;

In this example:

  • We define an async function fetchUsers that simulates an API call.
  • We call useAsync(fetchUsers, true). This tells the hook to run fetchUsers immediately when UserList mounts.
  • We destructure execute, status, value (aliased to users), and error from the hook’s return value.
  • Based on the status, we render different UI: a loading message, an error message, or the list of users.
  • The Refresh Users button demonstrates how you can manually trigger the execute function to re-fetch data.

Execution Environment and Lifecycle

The useAsync hook operates within the React component lifecycle, leveraging the power of hooks:

  • Initialization: When a component using useAsync first renders, the useState calls initialize status to 'idle', and value/error to null.
  • Immediate Execution (useEffect): The useEffect hook checks the immediate flag. If true, it calls execute(). This happens after the initial render.
  • Asynchronous Operation (execute): When execute is called, it immediately sets status to 'pending'. This state update triggers a re-render of the consuming component, which can then display a loading indicator.
  • Promise Resolution/Rejection: The asyncFunction runs.
  • If it resolves, setValue and setStatus('success') are called. These state updates trigger another re-render, displaying the fetched data.
  • If it rejects, setError and setStatus('error') are called. This also triggers a re-render, displaying the error message.
  • Dependencies: The dependency arrays in useCallback and useEffect are critical. They ensure that the execute function is stable across renders (unless asyncFunction changes) and that the effect runs only when necessary, preventing infinite loops or stale closures.
💡 Developer Tip: When passing an asyncFunction to useAsync that depends on props or state from the component, always wrap that asyncFunction definition in useCallback within your component. This ensures that useAsync‘s internal execute function doesn’t get unnecessarily re-created on every render, which could lead to unintended re-executions of your async task if immediate is true.

Leave a Reply

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