Hone logo
Hone
Problems

Create a useAsyncFn Hook for Efficient Asynchronous Operations in React

This challenge focuses on building a custom React hook, useAsyncFn, that simplifies managing asynchronous functions. This hook will help developers handle loading states, errors, and results from promises in a clean and declarative way within their React components, promoting better state management and user experience.

Problem Description

You are tasked with creating a reusable React hook called useAsyncFn that takes an asynchronous function as an argument and returns an object containing the function itself, its current execution state (loading, error, and value), and a way to trigger the function.

The hook should manage the following states:

  • loading: A boolean indicating whether the asynchronous function is currently executing.
  • error: An Error object if the asynchronous function throws an error, otherwise null.
  • value: The resolved value of the asynchronous function, otherwise null.

The hook should also return a callback function that, when called, will execute the original asynchronous function. This callback should accept the same arguments as the original asynchronous function.

Key Requirements:

  1. State Management: The hook must correctly update loading, error, and value states based on the promise's lifecycle.
  2. Function Execution: The returned callback function must be able to trigger the asynchronous operation and pass arguments to it.
  3. Type Safety: The hook should be strongly typed using TypeScript, allowing for generic types for the function's arguments and return value.
  4. Reusability: The hook should be generic and usable with any asynchronous function.
  5. Reset Mechanism (Optional but Recommended): Consider adding a way to reset the state of the hook (e.g., to an initial unloaded state).

Expected Behavior:

  • Initially, loading should be false, error should be null, and value should be null.
  • When the returned callback is invoked, loading should become true, and error and value should be reset to null.
  • If the promise resolves successfully, loading should become false, error should remain null, and value should be updated with the resolved data.
  • If the promise rejects, loading should become false, error should be updated with the rejected error, and value should remain null.
  • Subsequent calls to the callback should re-trigger the process, updating the states accordingly.

Edge Cases:

  • Handling race conditions: If the callback is called multiple times in quick succession, ensure that only the latest call's result updates the state.
  • Asynchronous functions that return non-promise values (though the primary use case is promises).

Examples

Example 1: Basic Usage with a Promise

import React, { useState } from 'react';
import { useAsyncFn } from './useAsyncFn'; // Assuming your hook is in this file

// Mock asynchronous function
const fetchUserData = async (userId: number): Promise<{ id: number; name: string }> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId === 1) {
        resolve({ id: 1, name: 'Alice' });
      } else {
        reject(new Error('User not found'));
      }
    }, 1000);
  });
};

function UserProfile({ userId }: { userId: number }) {
  const [state, execute] = useAsyncFn(fetchUserData);

  // Call fetchUserData when the component mounts or userId changes
  React.useEffect(() => {
    execute(userId);
  }, [userId, execute]);

  if (state.loading) {
    return <div>Loading user data...</div>;
  }

  if (state.error) {
    return <div>Error: {state.error.message}</div>;
  }

  if (state.value) {
    return (
      <div>
        <h2>User Profile</h2>
        <p>ID: {state.value.id}</p>
        <p>Name: {state.value.name}</p>
      </div>
    );
  }

  return <div>No user data loaded yet.</div>;
}

// In your App component:
// <UserProfile userId={1} />

Input: userId = 1 for fetchUserData Output:

<h2>User Profile</h2>
<p>ID: 1</p>
<p>Name: Alice</p>

Explanation: The useEffect calls execute(1), which starts the asynchronous operation. While loading, a "Loading..." message is displayed. Upon successful resolution, state.value is updated, and the user's profile is rendered.

Example 2: Handling Errors

// ... (previous imports and mock fetchUserData function)

function UserProfileWithError({ userId }: { userId: number }) {
  const [state, execute] = useAsyncFn(fetchUserData);

  React.useEffect(() => {
    execute(userId);
  }, [userId, execute]);

  if (state.loading) {
    return <div>Loading user data...</div>;
  }

  if (state.error) {
    return <div>Error fetching user: {state.error.message}</div>;
  }

  // ... (rest of rendering logic similar to Example 1)
  return null; // Placeholder
}

// In your App component:
// <UserProfileWithError userId={99} />

Input: userId = 99 for fetchUserData Output:

Error fetching user: User not found

Explanation: When execute(99) is called, the fetchUserData promise rejects. The state.error is populated, and the error message is displayed to the user.

Example 3: Manual Triggering

import React, { useState } from 'react';
import { useAsyncFn } from './useAsyncFn';

const createUser = async (name: string): Promise<{ id: string; name: string }> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: Math.random().toString(36).substring(7), name });
    }, 1500);
  });
};

function CreateUserForm() {
  const [userName, setUserName] = useState('');
  const [state, createUserFn] = useAsyncFn(createUser); // State is initially { loading: false, error: null, value: null }

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (userName.trim()) {
      createUserFn(userName.trim()); // Manually trigger the async function
    }
  };

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={userName}
          onChange={(e) => setUserName(e.target.value)}
          placeholder="Enter new user name"
        />
        <button type="submit" disabled={state.loading}>
          {state.loading ? 'Creating...' : 'Create User'}
        </button>
      </form>

      {state.error && <p style={{ color: 'red' }}>Error: {state.error.message}</p>}

      {state.value && (
        <p>
          User created successfully! ID: {state.value.id}, Name: {state.value.name}
        </p>
      )}
    </div>
  );
}

Input: User types "Bob" into the input field and clicks "Create User". Output (during loading): The "Create User" button becomes disabled and displays "Creating...".

Output (after successful creation): The button becomes enabled again, and a message like "User created successfully! ID: abcdef123, Name: Bob" is displayed. Explanation: The useAsyncFn hook is initialized with the createUser function. The handleSubmit function calls the returned createUserFn with the userName. The button's disabled state and text dynamically reflect the state.loading status.

Constraints

  • The hook must be implemented in TypeScript.
  • The asynchronous function passed to the hook can accept any number of arguments.
  • The asynchronous function passed to the hook can return any type of value (or a Promise that resolves to any type).
  • The hook should not introduce unnecessary re-renders.
  • The returned callback function should be memoized using useCallback to ensure stable identity.

Notes

  • Consider how you will handle the initial state and subsequent updates.
  • Think about the lifecycle of a promise: pending, fulfilled, and rejected.
  • The goal is to abstract away the common patterns of handling asynchronous operations in React.
  • Consider using a useRef to store the latest arguments to avoid stale closures when the callback is called.
  • The hook should return an array or an object containing the state and the execution function. An array [state, execute] is a common pattern.
Loading editor...
typescript