react-flow-modal
v0.6.0
Published
Promise-based modal flows for React
Readme
react-flow-modal
Promise-based modal flows for React.
react-flow-modal lets you treat modals as async flows using
Promise and async/await, without coupling your UI to state-driven logic.
Installation
pnpm add react-flow-modal
# or
npm install react-flow-modal
# or
yarn add react-flow-modalBasic Usage
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ModalProvider, useModal, ModalHost } from "react-flow-modal";
function ConfirmModal({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "grid",
placeItems: "center",
zIndex: 1000,
}}
>
<div
style={{
background: "white",
padding: 24,
borderRadius: 8,
minWidth: 300,
}}
>
<h3>Are you sure?</h3>
<p>This action cannot be undone.</p>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
</div>
</div>
);
}
function App() {
const modal = useModal();
const onClick = async () => {
// render modal and await resolve
const result = await modal.open("confirm", (resolve) => (
<ConfirmModal
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
/>
));
// flow resumed
console.log("Result:", result);
};
return <button onClick={onClick}>Open Confirm Modal</button>;
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ModalProvider>
<App />
<ModalHost />
</ModalProvider>
</StrictMode>,
)
With AnimatePresence (Framer Motion)
To support exit animations, modals must be rendered inside the same
React tree as AnimatePresence.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ModalHost, ModalProvider } from 'react-flow-modal';
import { AnimatePresence, motion } from 'motion/react';
function ConfirmModal({
onConfirm,
onCancel,
}: {
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<motion.div
key="confirm-modal-container"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "grid",
placeItems: "center",
zIndex: 1000,
}}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
style={{
background: "white",
padding: 24,
borderRadius: 8,
minWidth: 300,
color: "black",
}}
>
<h3>Are you sure?</h3>
<p>This action cannot be undone.</p>
<div style={{ display: "flex", gap: 8, marginTop: 16 }}>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
</motion.div>
</motion.div>
);
}
...
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ModalProvider>
<App />
<ModalHost>
{(modals) => (
<AnimatePresence>
{modals}
</AnimatePresence>
)}
</ModalHost>
</ModalProvider>
</StrictMode>,
)
API
useModal
const modal = useModal();Returns an object that controls the modal flow.
{
open<T>(
key: string,
render: (
resolve: (value: T) => void,
reject: (reason?: unknown) => void
) => React.ReactNode
): Promise<T>;
}ModalHost
const ModalHost: FC<{
children?: (modals: ReactElement[]) => React.ReactNode;
}>;Renders the entire modal stack. This component should be rendered once in your React tree.
Important
⚠️ Always resolve or reject the promise. Leaving it pending will block the async flow.
Why react-flow-modal?
Most modal libraries are state-driven:
setOpen(true);This makes modal control implicit and tightly coupled to rendering.
react-flow-modal treats modals as explicit async control points:
const result = await open(...);This keeps control flow readable, composable, and testable.
Features
- Headless API (no styles, no UI constraints)
- Promise-based modal control
- Internal stack management
- Render location fully controlled by the user
- Works naturally with async / await
License
MIT
