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.
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 */
}
};- 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.
AbortSignalsupport — cancel pending entries from the outside.- Entry strategies — recreate or reject duplicates by
id. - Deferred resolution for exit animations.
npm install @piotar/react-promise-bridge
# or: bun add / yarn add / pnpm addThere 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.
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);
}
}
⚠️ TheContainermust be mounted before you callopen, otherwiseopenthrowsContainerNotMountedException.
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
});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
});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>();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 | SettledReturns 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 });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);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 |
PromiseState—Initial,Pending,Settled(used byuseDeferredPromiseBridge).AbortSignalStrategy—Any(abort if any source signal aborts) /Every(only when all do); used byComposeAbortControllerwhen composing multiple signals.ComposeAbortController— anAbortControllerthat aborts based on a set of source signals.
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.
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
Containerunmounts (ContainerDestroyedException), when aRecreateentry is replaced (EntryRecreateException), and when anAbortSignalfires. Anopen(...)whose result you neverawait/catchwill surface as an unhandled promise rejection. For fire-and-forget, attach a no-op handler:open(<Toast message="Saved" />).catch(() => {});
-
Containermust be mounted beforeopen. Callingopenwith no mountedContainerrejects withContainerNotMountedException. By default only oneContainermay be mounted per bridge (a second throwsContainerLimitReachedException); opt into several withPromiseBridge.create({ isMultiContainer: true }). -
Multi-container mirrors the same element. With
isMultiContainer, an opened element is rendered in every mountedContainer— 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
Containerrejects its pending entries. Unmounting aContainer(conditional rendering, route changes, React Strict Mode's dev remount) dispatches a destroy that rejects any in-flight entries withContainerDestroyedException. Keep theContainermounted 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 ofisMultiContainerandcontainer, so an inline object is fine — but if you pass asignalarray touseDisposePromiseBridge, give it a stable reference (e.g.useMemo), since a new array identity each render recomposes the controller.
| Repository example | Open in StackBlitz |
|---|---|
| #i01 Material UI (MUI) | |
| #i02 React Bootstrap | |
| #i03 Ant design (antd) |
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.
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-sessionTo 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-bridgeEither way Claude gains a react-promise-bridge skill that knows when and how to bridge a React
component to an awaitable promise.
MIT © Piotr Tarasiuk