Hone logo
Hone
Problems

Implement a usePromise Hook in React

This challenge asks you to build a custom React hook, usePromise, that efficiently manages asynchronous operations and their states within your components. This hook will abstract away the boilerplate code typically associated with handling promises, making your React applications cleaner and more maintainable when dealing with data fetching or any other asynchronous tasks.

Problem Description

You need to create a TypeScript React hook named usePromise. This hook should take a promise-returning function as an argument and return the current state of the promise execution. The state should include:

  • data: The resolved value of the promise, or null if not yet resolved or if an error occurred.
  • error: The error object if the promise was rejected, or null if not yet rejected or if resolved successfully.
  • isLoading: A boolean indicating whether the promise is currently pending (i.e., the asynchronous operation is in progress).

The hook should automatically execute the provided promise-returning function when it's first mounted and re-execute it whenever any of its dependencies change.

Key Requirements:

  1. State Management: Track data, error, and isLoading states.
  2. Execution: The promise should execute when the hook is first used and when dependencies change.
  3. Dependencies: The hook should accept an optional array of dependencies, similar to useEffect and useCallback.
  4. Type Safety: Utilize TypeScript to ensure strong typing for the hook's arguments and return values.
  5. Cleanup: Handle potential race conditions by ensuring that only the result of the latest promise execution is reflected in the state. If the component unmounts or dependencies change before a promise resolves, its result should be ignored.

Expected Behavior:

  • Initially, isLoading should be true, data and error should be null.
  • Upon successful resolution, isLoading should become false, data should be updated with the resolved value, and error should be null.
  • Upon rejection, isLoading should become false, data should remain null, and error should be updated with the rejected reason.
  • If dependencies change, the promise should be re-executed, and the states should reset to their initial isLoading: true state before the new promise resolves or rejects.

Edge Cases:

  • Unmounting Component: If the component using the hook unmounts before the promise resolves, no state update should occur, preventing memory leaks.
  • Rapid Dependency Changes: If dependencies change rapidly, the hook should correctly handle only the latest promise execution.
  • Promise-Returning Function: The hook expects a function that returns a Promise, not a Promise itself.

Examples

Example 1: Basic Data Fetching

import React, { useState } from 'react';
import { usePromise } from './usePromise'; // Assuming usePromise is in './usePromise'

// A mock API call 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 { data: user, error, isLoading } = usePromise(() => fetchUserData(userId), [userId]);

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

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

    if (!user) {
        return <div>No user data available.</div>;
    }

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

// Usage in another component:
function App() {
    const [currentUserId, setCurrentUserId] = useState(1);
    return (
        <div>
            <UserProfile userId={currentUserId} />
            <button onClick={() => setCurrentUserId(currentUserId === 1 ? 2 : 1)}>
                Toggle User
            </button>
        </div>
    );
}

Input to usePromise:

  • promiseFn: () => fetchUserData(userId)
  • dependencies: [userId]

Expected Output (Initial render for userId = 1):

  • isLoading: true
  • data: null
  • error: null

Expected Output (After 1 second, if userId = 1):

  • isLoading: false
  • data: { id: 1, name: 'Alice' }
  • error: null

Example 2: Handling Rejection

Consider the UserProfile component from Example 1. If userId is set to 2, fetchUserData will reject.

Input to usePromise:

  • promiseFn: () => fetchUserData(2)
  • dependencies: [2]

Expected Output (After 1 second, if userId = 2):

  • isLoading: false
  • data: null
  • error: new Error('User not found')

Example 3: No Dependencies

If a promise should only execute once on mount, you can pass an empty dependency array or omit it.

import React from 'react';
import { usePromise } from './usePromise';

const fetchGlobalSettings = async (): Promise<{ theme: string }> => {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve({ theme: 'dark' });
        }, 1500);
    });
};

function AppSettings() {
    const { data: settings, isLoading, error } = usePromise(fetchGlobalSettings); // No dependencies provided

    if (isLoading) {
        return <div>Loading settings...</div>;
    }
    if (error) {
        return <div>Error loading settings: {error.message}</div>;
    }
    return <div>Theme: {settings?.theme}</div>;
}

Input to usePromise:

  • promiseFn: fetchGlobalSettings
  • dependencies: undefined (or [])

Expected Output: The promise runs once on mount. Subsequent re-renders of AppSettings will not re-trigger fetchGlobalSettings unless AppSettings is re-mounted.

Constraints

  • The usePromise hook must be implemented using TypeScript.
  • The hook should accept a generic type argument for the data that the promise resolves with.
  • The hook should accept a generic type argument for the error that the promise rejects with (defaulting to unknown or any if not specified).
  • Performance is important; avoid unnecessary re-renders of the component using the hook by correctly managing its internal state.
  • The promiseFn argument must be a function that returns a Promise.

Notes

  • Think about how to handle the AbortController pattern or similar mechanisms to cancel promises if the component unmounts or dependencies change before the promise resolves. This is crucial for preventing race conditions and memory leaks.
  • Consider using useRef to keep track of the latest promise and to manage the cleanup logic.
  • The hook should return an object with data, error, and isLoading.
  • The promiseFn might not always be a simple direct call. It could be a function that creates and returns a promise. Ensure your hook correctly invokes this function.
  • You'll need to import useState and useEffect (or useLayoutEffect if you have specific rendering needs, though useEffect is standard for async operations) from React.
  • Think about the types for data and error. data should be T | null, and error should be E | null, where T is the resolved type and E is the rejected type. isLoading should be boolean.
Loading editor...
typescript