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, ornullif not yet resolved or if an error occurred.error: The error object if the promise was rejected, ornullif 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:
- State Management: Track
data,error, andisLoadingstates. - Execution: The promise should execute when the hook is first used and when dependencies change.
- Dependencies: The hook should accept an optional array of dependencies, similar to
useEffectanduseCallback. - Type Safety: Utilize TypeScript to ensure strong typing for the hook's arguments and return values.
- 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,
isLoadingshould betrue,dataanderrorshould benull. - Upon successful resolution,
isLoadingshould becomefalse,datashould be updated with the resolved value, anderrorshould benull. - Upon rejection,
isLoadingshould becomefalse,datashould remainnull, anderrorshould be updated with the rejected reason. - If dependencies change, the promise should be re-executed, and the states should reset to their initial
isLoading: truestate 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:truedata:nullerror:null
Expected Output (After 1 second, if userId = 1):
isLoading:falsedata:{ 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:falsedata:nullerror: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:fetchGlobalSettingsdependencies: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
usePromisehook 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
unknownoranyif not specified). - Performance is important; avoid unnecessary re-renders of the component using the hook by correctly managing its internal state.
- The
promiseFnargument must be a function that returns aPromise.
Notes
- Think about how to handle the
AbortControllerpattern 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
useRefto keep track of the latest promise and to manage the cleanup logic. - The hook should return an object with
data,error, andisLoading. - The
promiseFnmight 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
useStateanduseEffect(oruseLayoutEffectif you have specific rendering needs, thoughuseEffectis standard for async operations) from React. - Think about the types for
dataanderror.datashould beT | null, anderrorshould beE | null, whereTis the resolved type andEis the rejected type.isLoadingshould beboolean.