How treating modals as typed async operations eliminates boolean state, callback chains, and runtime surprises in React apps.
React applications often treat modals as UI details.
A boolean flag. A conditional render. An onClose callback.
That works fine for one dialog.
But real products have modals that are actually business flows:
- confirm this destructive action
- rename this entity and return the new name
- pick a date range and apply it
- resolve a conflict before continuing
- complete a wizard step before the next one unlocks
These flows need more than a boolean.
They need typed input, typed output, and a way to await the result — just like any other async operation in your app.
const result = await modal.open(renameReportModal, {
reportId: report.id,
currentName: report.name,
});
if (result.status === "renamed") {
await renameReport({ id: report.id, name: result.name });
}
That is the idea behind:
npm install @okyrychenko-dev/react-modal-manager zustand
A modal lifecycle manager. Not a component. Not a design system.
A typed async contract between your app logic and your dialog UI.
The problem with traditional modal state
In most React apps, modal state starts locally:
function ReportsPage() {
const [isRenameOpen, setIsRenameOpen] = useState(false);
return (
<>
<button onClick={() => setIsRenameOpen(true)}>Rename</button>
{isRenameOpen && (
<RenameModal onClose={() => setIsRenameOpen(false)} />
)}
</>
);
}
And then real requirements arrive:
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isShareOpen, setIsShareOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<Report | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Report | null>(null);
const [shareTarget, setShareTarget] = useState<Report | null>(null);
The UI is not the problem.
The orchestration is:
- Where does the modal state live?
- How do we pass typed input into it?
- How do we get a typed result back?
- How do we open it from a command palette, keyboard shortcut, or service?
- How do we avoid leaking state between tests?
The Promise<any> problem
Several modal libraries already have a promise-based API. For example, @ebay/nice-modal-react is mature, popular, and widely used.
But the result type is often weak:
// nice-modal-react
const result = await NiceModal.show("rename-report", data);
// result is: any
result.nme; // ✅ TypeScript is silent — typo undetected
result.whatever; // ✅ TypeScript is silent — doesn't exist
result.name; // ✅ Fine — but compiler can't help you verify it
When result is any, TypeScript cannot verify the contract between the caller and the modal.
With a typed modal definition, the modal becomes the contract:
// @okyrychenko-dev/react-modal-manager
const result = await modal.open(renameReportModal, data);
// result is: { status: "renamed"; name: string } | { status: "cancelled" }
result.nme; // ❌ Property 'nme' does not exist on type...
result.whatever; // ❌ Property 'whatever' does not exist on type...
result.name; // ✅ Only available after narrowing result.status === "renamed"
The difference is not cosmetic. In long-lived codebases, untyped modal results are a source of silent runtime bugs and refactoring accidents.
Design goals
The library was built around a few constraints:
- modal input and result should be inferred by TypeScript
- modal state should be scoped to a provider, not leaked globally
- the UI layer should stay replaceable
- opening a modal should work both inside and outside React components
- closing, dismissing, and rejecting should be explicit lifecycle outcomes
Basic usage
Wrap your app with ModalProvider, then call useModalManager() from any descendant:
import {
ModalProvider,
useModalManager,
} from "@okyrychenko-dev/react-modal-manager";
function App() {
return (
<ModalProvider>
<ReportsPage />
</ModalProvider>
);
}
function DeleteButton() {
const modal = useModalManager();
async function handleDelete() {
const { confirmed } = await modal.confirm({
title: "Delete report?",
description: "This action cannot be undone.",
confirmText: "Delete",
variant: "danger",
});
if (!confirmed) return;
await deleteReport();
}
return <button onClick={handleDelete}>Delete</button>;
}
No lifted state. No callback chains. The flow reads sequentially.
Defining a typed modal
A modal is a React component with typed input and typed result:
import {
createModal,
type ModalComponentProps,
} from "@okyrychenko-dev/react-modal-manager";
interface RenameReportInput {
reportId: string;
currentName: string;
}
type RenameReportResult =
| { status: "renamed"; name: string }
| { status: "cancelled" };
function RenameReportModal({
input,
close,
}: ModalComponentProps<RenameReportInput, RenameReportResult>) {
const [name, setName] = useState(input.currentName);
return (
<dialog open>
<h2>Rename report</h2>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button onClick={() => close({ status: "cancelled" })}>Cancel</button>
<button onClick={() => close({ status: "renamed", name })}>Rename</button>
</dialog>
);
}
export const renameReportModal = createModal<RenameReportInput, RenameReportResult>({
component: RenameReportModal,
});
Now every call site gets full inference:
const result = await modal.open(renameReportModal, {
reportId: report.id,
currentName: report.name,
});
if (result.status === "renamed") {
await renameReport({ id: report.id, name: result.name });
}
TypeScript knows what input the modal requires, what shape can be passed to close(), and how to narrow the result. If you rename a field in RenameReportResult, every call site breaks at compile time — not at runtime.
The open handle
modal.open() returns a ModalHandle — a Promise with two extra properties:
const handle = modal.open(renameReportModal, {
reportId: report.id,
currentName: report.name,
});
// Identify this specific instance
console.log(handle.instanceId);
// Dismiss it from outside the modal component — from a timeout, a shortcut, another action
handle.dismiss();
// Still a regular Promise
const result = await handle;
This is useful when the caller needs to control the lifetime of the modal independently of the result — for example, closing a progress dialog when a background job finishes.
Opening modals from non-React code
Sometimes a modal needs to open from outside the React tree: a keyboard shortcut handler, a command palette, a domain service, a Redux thunk.
createModalRegistry creates a typed registry that can be called from anywhere:
// modals.ts
import { createModal, createModalRegistry } from "@okyrychenko-dev/react-modal-manager";
export const modals = createModalRegistry({
renameReport: createModal({ component: RenameReportModal }),
deleteReport: createModal({ component: DeleteReportModal }),
});
// App.tsx — bind it to the provider
<ModalProvider registry={modals}>
<App />
</ModalProvider>
// keyboard-shortcuts.ts — call it from anywhere
document.addEventListener("keydown", (e) => {
if (e.key === "F2" && currentReport) {
void modals.open("renameReport", {
reportId: currentReport.id,
currentName: currentReport.name,
});
}
});
The key is type-checked. The input is inferred from the registered modal. The result is inferred too — no string-based API, no Promise<any>.
Provider-scoped isolation
Modal state is isolated per ModalProvider. Each provider owns its own internal store — no module-level singleton.
<ModalProvider>
<AdminApp />
</ModalProvider>
<ModalProvider>
<PublicPreview />
</ModalProvider>
These two trees do not share modal state. This matters for:
- tests — each test mounts its own provider, no cross-test leakage
- Storybook — each story is isolated
- SSR and Next.js App Router — no shared state between requests
- micro-frontends — multiple app roots on the same page
Built-in confirm
modal.confirm() ships out of the box:
const { confirmed, reason } = await modal.confirm({
title: "Discard changes?",
description: "Your edits will be lost.",
variant: "warning",
});
if (confirmed) {
discard();
} else {
console.log(reason); // "cancel" | "dismiss"
}
The result is a discriminated union — no need to check typeof or cast anything.
The built-in confirm component includes an accessibility baseline:
-
role="dialog"witharia-modal,aria-labelledby,aria-describedby - Focus moves to the dialog on open and returns to the trigger on close
-
Tab/Shift+Tabare trapped within the dialog -
Escapedismisses (unlessdismissible: false) -
dangervariant focuses the cancel button by default — an accidental Enter never confirms a destructive action
It ships without styling. Production apps usually swap it for a design system version via confirmModal prop:
const myConfirm = createModal<ConfirmModalParams, ConfirmModalResult>({
component: MyDesignSystemConfirm,
});
<ModalProvider confirmModal={myConfirm}>
<App />
</ModalProvider>
UI-agnostic rendering
The library never prescribes how a modal should look. A renderer boundary lets you plug in any design system:
// Tailwind
function AppModalRenderer({ children, modal }: ModalRendererProps) {
return (
<div
data-status={modal.status}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50
transition-opacity data-[status=closing]:opacity-0"
>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
{children}
</div>
</div>
);
}
<ModalProvider closeDelayMs={150} renderer={AppModalRenderer}>
<App />
</ModalProvider>
When closeDelayMs > 0, a closed modal moves to status: "closing" before being removed — your CSS transition runs, then the instance is unmounted. Your promise settles immediately; the visual exit happens independently.
shadcn/ui works the same way. In a production app, wire onOpenChange to dismiss the modal when the user closes the dialog from the UI:
function ShadcnRenderer({ children, modal }: ModalRendererProps) {
const manager = useModalManager();
return (
<Dialog
open={modal.status === "open"}
onOpenChange={(open) => {
if (!open) {
manager.dismiss(modal.instanceId);
}
}}
>
<DialogContent>{children}</DialogContent>
</Dialog>
);
}
Compared to nice-modal-react
@okyrychenko-dev/react-modal-manager |
nice-modal-react |
|
|---|---|---|
| Result typing |
Promise<TResult>, fully inferred |
Promise<any> |
| State scope | Isolated per ModalProvider
|
Centralized provider/global-style modal store |
| Open from non-React code | Typed registry | Id-based global-style API |
| Built-in confirm | Typed + accessibility baseline | Not the main focus |
| UI coupling | UI-agnostic renderer | Integration helpers for specific libs |
| Maturity | New | 3+ years, battle-tested, large ecosystem |
nice-modal-react is a genuinely good library. If you want a mature modal manager with broad ecosystem usage and id-based convenience, it is a great choice.
This library takes a different trade-off: stricter types and explicit isolation over convenience and ecosystem breadth.
When this is a good fit
- Your modals return meaningful, typed results
- You want to
awaitmodal flows inline - You want TypeScript to catch call-site mismatches at compile time
- You use Next.js App Router, SSR, Storybook, or micro-frontends
- You need to open modals from action maps, services, or keyboard shortcuts
- You want UI-library agnosticism at the core
When it is not
- You have only one or two simple dialogs —
useStateis enough - Your modals do not return structured results
- You want a fully styled modal component out of the box
- You prefer a global string-based API over typed definitions
- You do not want a Zustand peer dependency
Final thoughts
A modal that asks "Are you sure?" before a destructive action is not a UI detail.
It is a checkpoint in a business flow. It has an outcome. That outcome affects what happens next.
Treating it as a boolean flag — or as Promise<any> — throws away information the compiler could use to keep your code correct.
const result = await modal.open(someModal, input);
Simple at the call site. Strict at the type level. Isolated at the provider boundary.
That is the bet this library makes: modal flows should be as type-safe as the rest of your application logic.
Try it out:
npm install @okyrychenko-dev/react-modal-manager
If you like the approach, drop a ⭐️ on the GitHub repo and let me know what you think in the comments! 👇
Top comments (0)