Introduction: Taming Asynchronous Operations in React
In modern web applications, asynchronous operations are ubiquitous. From fetching data and handling user authentication to managing complex multi-step forms, React developers constantly navigate the challenges of promises. While useEffect and useState can manage basic async tasks, orchestrating external events that resolve or reject promises often leads to cumbersome patterns, complex state management, and difficulty in managing loading states.
This guide introduces a powerful custom hook, usePromise, designed to provide granular control over the lifecycle of a Promise within your React components. By externalizing the promise's resolve and reject functions, you can initiate a promise from one part of your application and resolve it based on user interaction or external system events from another, all while seamlessly tracking its pending state.
Prerequisites
To make the most of this tutorial, a solid understanding of React hooks (especially useState, useRef, and useCallback) and fundamental JavaScript Promises is essential. Familiarity with TypeScript is highly recommended, as we'll leverage it to ensure type safety and improve developer experience for our custom hook.
The Challenge: External Control over Promise Resolution
Consider scenarios where a promise's resolution isn't immediate but depends on an external action. For example:
- OAuth2 / External Authentication Flows: Your application initiates an authentication request, but the promise resolves only after the user interacts with an external service (e.g., a browser redirect back to your app).
- Confirmation Dialogs: A user action (e.g., deleting an item) triggers a modal. The promise representing the deletion operation should only resolve or reject after the user clicks "Confirm" or "Cancel" within that modal.
- Multi-step Wizards: A step in a wizard needs to wait for an external validation or a user decision before proceeding.
Traditionally, managing such flows involves intricate state passing, event emitters, or deeply nested callbacks. usePromise simplifies this by exposing explicit resolve and reject functions.
Leveraging Promise.withResolvers() for Cleaner Promise Management
Before ES2023, creating a "deferred" promise (one whose resolve and reject functions are accessible externally) required a manual boilerplate. The new Promise.withResolvers() static method simplifies this significantly, providing a clean way to obtain the promise along with its resolve and reject functions directly.
While Promise.withResolvers() is relatively new, its adoption is growing. For environments that might not yet support it, a simple polyfill can be used, but for the purpose of this modern React tutorial, we'll assume its availability.
Building the usePromise Hook
Our usePromise hook will provide three core functionalities:
trigger: A function to initiate a new promise. If a previous promise was active, its resolution handlers are overwritten, and a new promise is returned.resolve: A function to fulfill the currently active promise with a value.reject: A function to reject the currently active promise with a reason.pending: A boolean state indicating whether there's an active, unresolved promise.
Let's break down the implementation of the usePromise hook into three logical pieces.
Initialization and State Setup
This initial part of the hook sets up the necessary React hooks for managing state and references, and defines the TypeScript interface for our deferred promise structure.
import { useCallback, useRef, useState } from "react";
interface PromiseDeferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
/**
* A custom React hook to manage and control the lifecycle of a Promise.
* It provides functions to trigger, resolve, and reject a promise externally,
* along with a pending state.
*
* @template T The type of the value the promise will resolve to.
*/
function usePromise<T>() {
const deferredRef = useRef<Omit<PromiseDeferred<T>, "promise"> | null>(null);
const [pending, setPending] = useState(false);
// ... rest of the hookThe trigger Function
The trigger function is the entry point for initiating a new promise. It's responsible for creating the promise and updating the pending state.
// ... previous code
const trigger = useCallback((): Promise<T> => {
if (deferredRef.current) {
// Read the explanation below
}
const { promise, resolve, reject } = Promise.withResolvers<T>();
deferredRef.current = { resolve, reject };
setPending(true);
return promise;
}, []);
// ...Explanation:
if (deferredRef.current) { ... }: This block and its comments highlight a design decision. As implemented,triggeralways creates a new promise. If a previous promise was still active (i.e.,deferredRef.currentwas notnull), itsresolveandrejectfunctions would be overwritten by the new ones. This means callingtriggermultiple times will abandon any previously unfulfilled promises, ensuring only one active deferred promise at a time is managed by the hook.deferredRef.current = { resolve, reject };: We store theresolveandrejectfunctions in ouruseRefso they can be accessed and called externally to control the promise's lifecycle.
Piece 3: Controlling Resolution and Hook Output
This final section defines the functions to complete the promise's lifecycle (resolve and reject) and specifies what the usePromise hook makes available to consuming components.
// ... previous code
const resolve = useCallback((value: T) => {
if (!deferredRef.current) return;
deferredRef.current.resolve(value);
deferredRef.current = null; // Clear the deferred reference after resolution
setPending(false);
}, []);
const reject = useCallback((reason?: unknown) => {
if (!deferredRef.current) return;
deferredRef.current.reject(reason);
deferredRef.current = null; // Clear the deferred reference after rejection
setPending(false);
}, []);
return {
trigger,
resolve,
reject,
pending,
};
}Explanation:
resolve = useCallback(...): This function is called to fulfill the active promise with a specificvalue.if (!deferredRef.current) return;: A check ensures that there's an active promise to resolve. IfdeferredRef.currentisnull, it means no promise is pending or it has already been resolved/rejected.
reject = useCallback(...): This function is called to reject the active promise with areason(error). Its logic is very similar toresolve, but it calls therejectfunction of the stored promise.- It also clears
deferredRef.currentand setspendingtofalseupon completion.
- It also clears
Real-World Example: A Confirmation Dialog
Let's illustrate the power of usePromise with a common scenario: a confirmation dialog that prompts the user before performing a destructive action like deleting an item.
First, define the usePromise hook in a file like hooks/usePromise.ts.
Next, we'll integrate usePromise directly with shadcn/ui's Dialog components in our main application component. Ensure you have the Dialog and Button components installed from shadcn/ui (e.g., using npx shadcn-ui@latest add dialog button).
// App.tsx
import usePromise from './hooks/usePromise'; // Assuming usePromise is in hooks/usePromise.ts
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
function App() {
const { trigger, resolve, reject, pending } = usePromise<boolean>();
const handleDeleteItem = async () => {
try {
// Trigger the promise and wait for the dialog to resolve it
const confirmed = await trigger();
if (confirmed) {
// Simulate an API call
console.log("Item deletion confirmed. Performing API call...");
// await someDeleteApiCall();
} else {
console.log("Item deletion cancelled by user.");
}
} catch (error) {
console.error("Deletion failed:", error);
}
};
return (
<div>
<Button
onClick={handleDeleteItem}
disabled={pending}
>
{pending ? 'Waiting for confirmation...' : 'Delete Item'}
</Button>
<Dialog open={pending} onOpenChange={(isOpen) => {
// If the dialog is being closed externally (e.g., by pressing ESC or clicking outside)
// and a promise is still pending, treat it as a cancellation.
if (!isOpen && pending) {
resolve(false);
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
<DialogDescription>
Are you sure you want to delete this item? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => resolve(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={() => resolve(true)}>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default App;In this example:
- When
handleDeleteItemis called, itawaitstrigger(), which immediately setspendingtotrueand returns a promise. - The
Appcomponent conditionally renders theDialogbecause itsopenprop is bound topending. - When the user clicks "Confirm" in the dialog,
resolve(true)is called, fulfilling the promiseawaited inhandleDeleteItem. - If the user clicks "Cancel,"
resolve(false)is called, also fulfilling the promise. - If the user closes the dialog externally (e.g., by pressing
Escape), theonOpenChangehandler catches this and callsresolve(false)to treat it as a cancellation. handleDeleteItemthen proceeds based on theconfirmedboolean, updating the status.
This elegantly decouples the promise initiation from its resolution, making complex asynchronous workflows much more manageable and readable, all while leveraging the clean, pre-styled components from shadcn/ui.
Further Enhancements: Centralizing Callbacks and Error Handling
While the current usePromise hook is powerful, we can enhance it further by allowing consumers to provide onResolve and onReject callback functions directly as props. This centralizes success and error handling within the hook's declaration, ensuring these callbacks are always invoked when the promise resolves or rejects, regardless of where resolve or reject are called. This also allows the hook to "manually" handle the promise's lifecycle with internal .then().catch() logic.
This approach means that any await trigger() call will still return the promise, but the provided onResolve and onReject callbacks will execute in addition to any .then() or .catch() chained by the caller. This can be particularly useful for logging, global notifications, or specific side effects that should always happen upon resolution or rejection.
Here's how we could modify the usePromise hook to accept these callbacks:
import { useCallback, useRef, useState } from "react";
interface PromiseDeferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
// New interface for the hook's props
interface UsePromiseProps<T, E = unknown> {
onResolve?: (data: T) => void;
onReject?: (error: E) => void;
}
function usePromise<T, E = unknown>(props?: UsePromiseProps<T, E>) {
const deferredRef = useRef<Omit<PromiseDeferred<T>, "promise"> | null>(null);
const [pending, setPending] = useState(false);
const trigger = useCallback((): Promise<T> => {
if (deferredRef.current) {
// Potentially reject the old promise before creating a new one,
// or simply overwrite as the original design dictates.
// For this example, we maintain the overwrite behavior.
}
const { promise, resolve, reject } = Promise.withResolvers<T>();
deferredRef.current = { resolve, reject };
setPending(true);
// Manually handle the promise resolution/rejection internally
// and invoke the provided callbacks. This effectively "wraps"
// the promise's lifecycle for additional side effects.
promise
.then((data) => {
props?.onResolve?.(data);
})
.catch((error) => {
props?.onReject?.(error as E); // Type assertion for error
});
return promise;
}, [props?.onResolve, props?.onReject]);
const resolve = useCallback((value: T) => {
if (!deferredRef.current) return;
deferredRef.current.resolve(value);
deferredRef.current = null;
setPending(false);
}, []);
const reject = useCallback((reason?: unknown) => {
if (!deferredRef.current) return;
deferredRef.current.reject(reason);
deferredRef.current = null;
setPending(false);
}, []);
return {
trigger,
resolve,
reject,
pending,
};
}With this improved usePromise hook, you can now provide global handlers for resolution and rejection directly when initializing the hook:
const { trigger, resolve, reject, pending } = usePromise<string, Error>({
onResolve: (data) => console.log("Success! Data:", data),
onReject: (error) => console.error("Failed! Error:", error.message),
});This makes error handling and success notifications more streamlined, especially when the promise's outcome has broader application-level implications beyond the immediate component awaiting its resolution.
Conclusion
The usePromise hook, particularly with the Promise.withResolvers() enhancement and centralized callback management, offers a robust and elegant solution for intricate asynchronous flows in React. It empowers developers to orchestrate complex interactions, improve state management, and write cleaner, more maintainable code by externalizing promise control. Embrace usePromise to confidently master asynchronous patterns in your React applications.