Mastering Asynchronous Flows: The React usePromise Hook

January 23, 2026

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:

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:

  1. 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.
  2. resolve: A function to fulfill the currently active promise with a value.
  3. reject: A function to reject the currently active promise with a reason.
  4. 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 hook

The 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:

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:

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:

  1. When handleDeleteItem is called, it awaits trigger(), which immediately sets pending to true and returns a promise.
  2. The App component conditionally renders the Dialog because its open prop is bound to pending.
  3. When the user clicks "Confirm" in the dialog, resolve(true) is called, fulfilling the promise awaited in handleDeleteItem.
  4. If the user clicks "Cancel," resolve(false) is called, also fulfilling the promise.
  5. If the user closes the dialog externally (e.g., by pressing Escape), the onOpenChange handler catches this and calls resolve(false) to treat it as a cancellation.
  6. handleDeleteItem then proceeds based on the confirmed boolean, 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.

Resources