Skip to content

Repository files navigation

react-promise-bridge

GitHub package.json version npm (scoped) NPM

Render a React component and await the value it produces — as a Promise.

react-promise-bridge bridges the imperative world (call a function, get a result back) with the declarative world of React (render a component). You call open(<Dialog />), the component is mounted for you, and from inside it you call resolve(value) or reject(reason) to settle the promise and unmount the component.

It's a tiny, dependency-free primitive for anything that "asks the user something and returns an answer": confirm dialogs, modals, popups, toasts, notifications, color pickers, wizards — you own the UI, the library only owns the promise.

Why

Without it, asking the user a question means juggling state, callbacks, and conditional rendering:

// ❌ state + callbacks scattered across the component
const [confirmOpen, setConfirmOpen] = useState(false);
const onDelete = () => setConfirmOpen(true);
// ...somewhere in JSX:
{confirmOpen && <Confirm onYes={reallyDelete} onNo={() => setConfirmOpen(false)} />}

With react-promise-bridge the question reads top-to-bottom, like any other async call:

// ✅ imperative, linear, colocated
const onDelete = async () => {
    try {
        await open(<Confirm message="Delete this item?" />);
        reallyDelete();
    } catch {
        /* user cancelled */
    }
};

Features

  • Lightweight, zero dependencies (only a React peer dependency, >=16.6).
  • open() works inside and outside React — call it from event handlers, effects, or plain JS.
  • No prop drilling — the component talks back through React context, not props.
  • Bring your own component — no wrapper, no required props; existing components just work.
  • Multiple independent bridges and multiple / nested containers.
  • AbortSignal support — cancel pending entries from the outside.
  • Entry strategies — recreate or reject duplicates by id.
  • Deferred resolution for exit animations.

Installation

npm install @piotar/react-promise-bridge
# or: bun add / yarn add / pnpm add

Mental model

There are exactly three pieces:

Piece What it is Who uses it
Container Where opened components are mounted rendered once, anywhere in your tree (or a separate root)
open(element) Mounts element and returns a Promise your app — event handlers, effects, plain JS
usePromiseBridge() Gives the mounted component { resolve, reject, signal } the component passed to open()

Container and open come as a pair from PromiseBridge.create(). Calling resolve/reject settles the promise returned by open and unmounts the component.

Quick start

1. Create a bridge (a Container + its open function):

// SystemPromiseBridge.ts
import { PromiseBridge } from '@piotar/react-promise-bridge';

// Names are yours to choose.
export const [Container, open] = PromiseBridge.create();

2. Mount the Container once — anywhere in the tree, or on a dedicated DOM root:

// main.tsx
import { Container } from './SystemPromiseBridge';

root.render(
    <App>
        {/* ...your app... */}
        <Container />
    </App>,
);

// or fully detached from the app tree:
// createRoot(document.getElementById('modals')!).render(<Container />);

3. Build a component that settles the promise via usePromiseBridge:

// Confirm.tsx
import { usePromiseBridge } from '@piotar/react-promise-bridge';

export function Confirm({ message }: { message: string }) {
    // <boolean> is the resolve type; reject reason defaults to `any`.
    const { resolve, reject } = usePromiseBridge<boolean>();

    return (
        <dialog open>
            <p>{message}</p>
            <button onClick={() => reject(new Error('Cancelled'))}>Cancel</button>
            <button onClick={() => resolve(true)}>Confirm</button>
        </dialog>
    );
}

4. open it and await the result — from inside or outside React:

import { open } from './SystemPromiseBridge';

async function confirmDelete() {
    try {
        await open<boolean>(<Confirm message="Delete this item?" />);
        console.log('confirmed');
    } catch (error) {
        console.warn('cancelled', error);
    }
}

⚠️ The Container must be mounted before you call open, otherwise open throws ContainerNotMountedException.

Open in StackBlitz

API

PromiseBridge.create(options?)

Returns [Container, open].

const [Container, open] = PromiseBridge.create({
    isMultiContainer: false, // allow more than one mounted Container for this bridge
    // container: CustomContainerComponent, // advanced: override the container renderer
});

open(element, options?) => Promise<T>

Mounts element in the Container and resolves/rejects when the component calls resolve/reject. Type the result with open<T>(...).

open<string>(<ColorPicker />, {
    signal,                          // AbortSignal — abort cancels the pending entry
    strategy: EntryStrategy.Recreate, // how to treat an entry with the same id
    id: 'colorPicker',               // required by Recreate / RejectIfExists strategies
});

Hooks

usePromiseBridge<T, R>()

The core hook. Read inside a component opened by open. Throws MissingContextException if used outside a bridged component.

const { resolve, reject, signal } = usePromiseBridge<ResolveType, RejectType>();

Example

useDeferredPromiseBridge<T, R>()

For exit animations. resolve/reject don't settle immediately — they move state to Pending so you can keep the component mounted and play a closing animation, then call trigger (e.g. in onAnimationEnd) to actually settle and unmount.

const { resolve, reject, signal, state, trigger, Provider } =
    useDeferredPromiseBridge<ResolveType, RejectType>();
// state is one of PromiseState.Initial | Pending | Settled

Example

useDisposePromiseBridge(signals?)

Returns an AbortController whose signal aborts when the calling (trigger) component unmounts — pass signal to open so any in-flight entry is rejected automatically when its opener disappears.

const { signal } = useDisposePromiseBridge();
await open(<ColorPicker />, { signal });

Example

useFactoryPromiseBridge(options?)

Creates a bridge scoped to the component instance (memoized), instead of a module-level singleton. Useful for nested/dynamic containers (e.g. a drawer that can open another drawer).

const [Container, open] = useFactoryPromiseBridge(options);

Example

Entry strategies — EntryStrategy

Passed via open(element, { strategy, id }) to control duplicate entries:

Strategy Behaviour Needs id
EntryStrategy.Normal (default) Always opens a new entry. no
EntryStrategy.Recreate Rejects an existing entry with the same id (with EntryRecreateException), then opens a fresh one. yes
EntryStrategy.RejectIfExists If an entry with the same id already exists, the new open throws EntryExistsException. yes

Enums & types

  • PromiseStateInitial, Pending, Settled (used by useDeferredPromiseBridge).
  • AbortSignalStrategyAny (abort if any source signal aborts) / Every (only when all do); used by ComposeAbortController when composing multiple signals.
  • ComposeAbortController — an AbortController that aborts based on a set of source signals.

Exceptions

All extend PromiseBridgeException, so you can catch broadly or narrow by class. Among them: ContainerNotMountedException, ContainerDestroyedException, ContainerLimitReachedException, EntryExistsException, EntryRecreateException, MissingEntryIdException, MissingContextException, TriggerComponentDestroyedException, and the abort-related EntryAbortedBySignalException / ExternalAbortSignalException.

Caveats & gotchas

A few behaviours are intentional but easy to trip over:

  • Always handle the promise — even fire-and-forget. A cancel is a rejection, and the library also rejects pending entries on its own: when the Container unmounts (ContainerDestroyedException), when a Recreate entry is replaced (EntryRecreateException), and when an AbortSignal fires. An open(...) whose result you never await/catch will surface as an unhandled promise rejection. For fire-and-forget, attach a no-op handler:

    open(<Toast message="Saved" />).catch(() => {});
  • Container must be mounted before open. Calling open with no mounted Container rejects with ContainerNotMountedException. By default only one Container may be mounted per bridge (a second throws ContainerLimitReachedException); opt into several with PromiseBridge.create({ isMultiContainer: true }).

  • Multi-container mirrors the same element. With isMultiContainer, an opened element is rendered in every mounted Container — i.e. the same component mounts more than once with independent local state. Settling from any copy resolves the single shared promise. Use it for intentional mirroring, not as a way to "pick" one container.

  • Remounting a Container rejects its pending entries. Unmounting a Container (conditional rendering, route changes, React Strict Mode's dev remount) dispatches a destroy that rejects any in-flight entries with ContainerDestroyedException. Keep the Container mounted for the lifetime of the flows it serves; in practice it mounts empty, so Strict Mode's double-invoke is harmless.

  • Pass stable options to useFactoryPromiseBridge. The bridge is memoized by the values of isMultiContainer and container, so an inline object is fine — but if you pass a signal array to useDisposePromiseBridge, give it a stable reference (e.g. useMemo), since a new array identity each render recomposes the controller.

Examples

Repository example Open in StackBlitz
#01 Basic Open in StackBlitz
#02 Animation Open in StackBlitz
#03 Animation with classes Open in StackBlitz
#04 Abort controller Open in StackBlitz
#05 Entry with strategy recreate Open in StackBlitz
#06 Entry with strategy reject if exists Open in StackBlitz
#07 Multicontainer Open in StackBlitz
#08 Destroy Bridge after destroy trigger component Open in StackBlitz

Integrations with UI libraries

Repository example Open in StackBlitz
#i01 Material UI (MUI) Open in StackBlitz
#i02 React Bootstrap Open in StackBlitz
#i03 Ant design (antd) Open in StackBlitz

AI agents

A SKILL.md (agentskills.io format) ships with the package so the library can be taught to AI agents and installed via a skill manager.

Claude Code plugin

The package doubles as a Claude Code plugin — a .claude-plugin/plugin.json manifest exposes the bundled SKILL.md as a react-promise-bridge skill. Load it straight from node_modules (no separate install):

claude --plugin-dir node_modules/@piotar/react-promise-bridge     # per-session

To make the skill available globally instead, symlink the installed package into your skills directory — it then auto-loads in every session:

ln -s "$(npm root)/@piotar/react-promise-bridge" ~/.claude/skills/react-promise-bridge

Either way Claude gains a react-promise-bridge skill that knows when and how to bridge a React component to an awaitable promise.

License

MIT © Piotr Tarasiuk

About

A library designed to manage components that ultimately return a value as a Promise.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages