Hone logo
Hone
Problems

Implement a useAsync Hook for Asynchronous Operations in React

Asynchronous operations are fundamental to modern web applications, often involving fetching data from APIs, performing complex computations, or interacting with external services. Managing the loading, error, and success states of these operations within React components can become repetitive and verbose. Your task is to create a custom React hook, useAsync, that abstracts this common pattern, making it cleaner and more reusable.

Problem Description

You need to implement a TypeScript React hook named useAsync. This hook should simplify the management of asynchronous functions and their corresponding states (loading, error, and data).

The useAsync hook should:

  • Accept an asynchronous function (a function that returns a Promise) as its primary argument.
  • Accept an optional immediate execution flag. If true, the async function should be executed immediately when the hook is first used.
  • Return an object containing:
    • data: The result of the asynchronous operation (initially null or undefined).
    • error: Any error thrown during the operation (initially null or undefined).
    • loading: A boolean indicating whether the asynchronous operation is currently in progress (initially false, or true if immediate execution is requested).
    • run: A function that can be called to manually execute the asynchronous operation. This function should accept any arguments needed by the original async function.
    • reset: A function to reset the hook's state to its initial values.

Key Requirements:

  • The hook must correctly manage the loading state, setting it to true before the promise starts and false after it resolves or rejects.
  • It must capture the resolved data or the error thrown by the promise.
  • The run function should allow re-execution of the async operation with new arguments. Subsequent calls to run should reset data and error and set loading to true.
  • The hook should handle potential race conditions if run is called multiple times quickly. Only the result of the latest initiated operation should be reflected in the data or error state.
  • The reset function should revert data, error, and loading to their initial states.

Expected Behavior:

  1. Initial State: When useAsync is called without immediate execution, data, error will be null/undefined, and loading will be false.
  2. Immediate Execution: If immediate is true, the async function will run upon hook initialization, and loading will be true until completion.
  3. Manual Execution (run): Calling run with arguments (if any) will set loading to true, reset data and error, execute the async function, and update data or error upon completion, setting loading to false.
  4. Error Handling: If the async function throws an error, the error state will be updated, data will be null/undefined, and loading will be false.
  5. Race Condition Handling: If run is called again while a previous operation is still pending, the results of the older operation should be ignored.
  6. Resetting State (reset): Calling reset should restore the hook's state to its initial configuration.

Examples

Example 1: Basic Data Fetching

// Assume a utility function for fetching data
const fakeFetch = (url: string): Promise<string> =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      if (url === "/data") {
        resolve("Successfully fetched data!");
      } else {
        reject(new Error("Not Found"));
      }
    }, 500);
  });

// In a React Component:
function MyComponent() {
  const { data, error, loading, run } = useAsync(fakeFetch, false);

  return (
    <div>
      <button onClick={() => run("/data")} disabled={loading}>
        Fetch Data
      </button>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      {data && <p>{data}</p>}
    </div>
  );
}

Input to useAsync: fakeFetch (async function), false (immediate execution flag). Output (Initial): { data: undefined, error: undefined, loading: false, run: [function], reset: [function] } Explanation: The hook is initialized, and the run function is available for manual invocation.

After clicking the "Fetch Data" button: Output: { data: "Successfully fetched data!", error: undefined, loading: false, run: [function], reset: [function] } Explanation: run("/data") was called. loading became true, fakeFetch resolved with the data, which was then stored in the data state, and loading became false.

Example 2: Immediate Execution with Error

const faultyFetch = (): Promise<string> =>
  new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error("Something went wrong!"));
    }, 500);
  });

// In a React Component:
function AnotherComponent() {
  const { data, error, loading } = useAsync(faultyFetch, true); // Immediate execution

  return (
    <div>
      {loading && <p>Loading initially...</p>}
      {error && <p>Initial Fetch Error: {error.message}</p>}
      {data && <p>{data}</p>}
    </div>
  );
}

Input to useAsync: faultyFetch (async function), true (immediate execution flag). Output (Initial/After ~500ms): { data: undefined, error: Error("Something went wrong!"), loading: false, run: [function], reset: [function] } Explanation: The hook immediately executed faultyFetch. The promise rejected, and the error was captured. loading was true during execution and set to false afterward.

Example 3: Handling Race Conditions

const slowFetch = (id: number): Promise<string> =>
  new Promise((resolve) => {
    setTimeout(() => resolve(`Data for ${id}`), 1000);
  });

// In a React Component:
function RaceComponent() {
  const { data, error, loading, run } = useAsync(slowFetch, false);

  const handleClick = () => {
    run(1); // Start fetching for ID 1
    setTimeout(() => {
      run(2); // Immediately start fetching for ID 2 (should cancel the first)
    }, 100);
  };

  return (
    <div>
      <button onClick={handleClick} disabled={loading}>
        Fetch Sequentially (Race)
      </button>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      {data && <p>{data}</p>}
    </div>
  );
}

Input to useAsync: slowFetch (async function), false. Output (After clicking button): { data: "Data for 2", error: undefined, loading: false, run: [function], reset: [function] } Explanation: The handleClick calls run(1). 100ms later, run(2) is called. Even though slowFetch(1) is still pending, the hook's internal mechanism should ensure that only the result of slowFetch(2) (which finishes later) is stored in the data state. The loading state would have been true during both operations and then false after the second one completed.

Constraints

  • The useAsync hook must be implemented in TypeScript.
  • The hook should not rely on any external state management libraries (e.g., Redux, Zustand).
  • The hook should be efficient and avoid unnecessary re-renders.
  • The asynchronous function passed to the hook must return a Promise.

Notes

  • Consider how to manage cancellation of promises to prevent race conditions. A common pattern is to use a flag or an AbortController if the underlying async operation supports it. For this challenge, you can simulate cancellation by simply ignoring results from older promises based on a unique identifier or a counter.
  • Think about the initial state of data and error. undefined or null are common choices.
  • The run function should be able to accept zero or more arguments, matching the signature of the provided asynchronous function.
  • Ensure proper type safety using TypeScript generics to infer the types of data and error.
Loading editor...
typescript