# react-call: Awaiting React Components Like Async Functions

[react-call](https://github.com/desko27/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.

<iframe width="100%" height="315" src="https://www.youtube.com/embed/K8TAc_EtDVc" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


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

```javascript
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](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/d2dd6926-b0e9-401b-6cc8-b6e7e9e5c400/orig =1280x720)

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

```javascript
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](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/54c0cc28-3fe8-4071-ae8e-c3d05ef7a200/lg1x =1280x720)

### 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](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/cd2cc439-73c6-4286-c506-d2c874aea300/orig =1280x720)

### 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

```tsx
[label 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](https://imagedelivery.net/xZXo0QFi-1_4Zimer-T0XQ/0d0906a0-fe6f-496f-7c77-23885eda0b00/orig =1280x720)

`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

```tsx
[label 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

```tsx
[label 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](https://github.com/desko27/react-call).