Back to Scaling Node.js applications guides

react-call: Awaiting React Components Like Async Functions

Stanley Ulili
Updated on June 15, 2026

react-call is a sub-1KB, zero-dependency React library that allows interactive UI elements (modals, dialogs, toasts) to be triggered imperatively and awaited like async functions. Instead of managing visibility state and callback handlers, components wrapped with createCallable can be called with .call() and their result awaited directly in async event handlers.

The traditional approach and its friction

A typical confirmation modal requires at least one visibility state variable, one variable for contextual data, and multiple handler functions per modal:

 
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);

const handleDeleteClick = (item) => {
  setItemToDelete(item);
  setIsDeleteModalOpen(true);
};

const handleConfirmDelete = () => {
  // delete logic
  setIsDeleteModalOpen(false);
};

When the triggering component is deep in the tree and the modal renders at the top, passing state and handlers down requires either prop drilling through unrelated components or a Context provider with its own boilerplate.

Traditional complex wiring required to open a simple modal

The react-call approach

react-call draws on the mental model of window.confirm(): execution pauses, the user responds, and the result is returned. The same flow with a custom styled component:

 
const handleClick = async () => {
  const ok = await Confirm.call({ message: "Delete this file?" });
  if (ok) {
    await deleteFile();
  }
};

All state management for visibility and promise resolution is internal to the library.

Three-step setup

Declare. Wrap the component with createCallable. This returns a callable version of the component.

Root. Render the callable component once in the tree (typically in App.tsx). This instance acts as a mounting point. It renders nothing until called.

Call & Await. From anywhere in the application, trigger the component with .call() in an async function and await the result.

Core concepts

Root and Stack

Each createCallable component has a Root and an internal Stack. When .call() is triggered, the Root renders an instance of the component. If .call() is triggered again before the first instance resolves, the new instance is pushed onto the stack and rendered on top. Resolved instances are removed. This allows nested dialogs (a confirmation opening another confirmation) where each instance is isolated and manages its own promise.

Interactive Stack demo showing dynamically added and removed layered modal instances

Upsert

Some elements should only ever have one visible instance: toasts, progress overlays, "saving..." indicators. For these, .upsert() behaves like .call() on the first invocation but updates the props of the existing instance on subsequent calls instead of creating a new one. A file upload toast that needs to reflect changing progress can call upsert repeatedly with the new percentage, and the same component instance re-renders in place.

Interactive Upsert demo showing repeated button clicks updating a single component instance instead of creating new ones

useMutationFlow

For dialogs that trigger async actions, useMutationFlow wires the callable component to an async function and manages the lifecycle:

  • Tracks the pending state automatically
  • Closes the dialog on success
  • Keeps it open on failure, preserving any user-entered data for retry

This "stay-open-on-failure" guarantee is important for forms and actions where the user needs to correct and resubmit.

Complete example: confirmation dialog

Declare the callable component

src/components/Confirm.tsx
import { createCallable } from 'react-call';

interface Props {
  message: string;
}

type Response = boolean;

const Confirm = createCallable<Props, Response>(({ call, message }) => {
  return (
    <div className="fixed inset-0 flex items-center justify-center bg-black/50">
      <div role="dialog" className="bg-white p-6 rounded-lg shadow-xl">
        <p className="text-lg mb-4">{message}</p>
        <div className="flex justify-end gap-3">
          <button onClick={() => call.end(false)}>Cancel</button>
          <button onClick={() => call.end(true)}>Confirm</button>
        </div>
      </div>
    </div>
  );
});

export default Confirm;

Example of callable component code showing the createCallable wrapper and call.end() in event handlers

call.end() resolves the promise with the provided value. Passing true or false here is what the await Confirm.call(...) expression receives.

Mount the Root

src/App.tsx
import Confirm from './components/Confirm';
import DeleteButton from './components/DeleteButton';

function App() {
  return (
    <main>
      <DeleteButton />
      <Confirm />
    </main>
  );
}

<Confirm /> renders nothing until called. It only needs to appear once in the tree.

Call and await from a consumer

src/components/DeleteButton.tsx
import { useState } from 'react';
import Confirm from './Confirm';

const DeleteButton = () => {
  const [status, setStatus] = useState<'idle' | 'deleting' | 'deleted'>('idle');

  const handleClick = async () => {
    const confirmed = await Confirm.call({
      message: 'Delete this item? This action cannot be undone.',
    });

    if (confirmed) {
      setStatus('deleting');
      await new Promise(resolve => setTimeout(resolve, 1000));
      setStatus('deleted');
    }
  };

  return (
    <button onClick={handleClick} disabled={status !== 'idle'}>
      {status === 'idle' && 'Delete Item'}
      {status === 'deleting' && 'Deleting...'}
      {status === 'deleted' && 'Deleted!'}
    </button>
  );
};

The modal's visibility state, promise resolution, and cleanup are all managed by react-call. The component's event handler contains only the business logic.

Final thoughts

react-call works best for interactive elements where the result is needed in the same scope that triggered them. Confirmation dialogs, file pickers, and form-based modals fit this pattern well. The Upsert pattern is the right tool when you need singleton UI elements like toasts or progress indicators that update rather than stack.

The library's constraint is that it requires components to be wrapped at definition time and requires a Root to be mounted. This is a minor upfront cost for a significant reduction in per-modal boilerplate as an application grows.

Source code and documentation are at github.com/desko27/react-call.

Got an article suggestion? Let us know
Next article
Running Node.js Apps with PM2 (Complete Guide)
Learn the key features of PM2 and how to use them to deploy, manage, and scale your Node.js applications in production
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.