The original article behind a new four-part series on typed async modal architecture in React. This version now serves as a short introduction and guide to the updated series.
Update: This article has been substantially rewritten and expanded into a four-part series about typed async modal architecture in React.
If you are reading this for the first time, I recommend starting with:
When I originally wrote this article, the central idea was simple:
A modal that participates in application logic is often more useful when modeled as an asynchronous operation with typed input and a typed result.
Instead of coordinating a flow through scattered boolean state, temporary payload state, and callbacks:
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<Report | null>(null);
the caller can express the interaction sequentially:
const result = await modal.open(renameReportModal, {
reportId: report.id,
currentName: report.name,
});
if (result.status === "renamed") {
await renameReport({
id: report.id,
name: result.name,
});
}
The important part is not only the Promise.
It is the contract:
Modal<TInput, TResult>
A modal receives typed input, produces a typed result, and keeps the application flow at the call site.
That idea is still the foundation of
@okyrychenko-dev/react-modal-manager.
However, the library and my own thinking about the abstraction have evolved since this article was first published.
The original version mixed several different topics into one long article:
- why modal orchestration can be modeled as an async operation;
- why
Promise<any>weakens that abstraction; - how provider-scoped lifecycle state works;
- how imperative registries fit into the architecture;
- confirm dialogs and accessibility;
- rendering and design-system integration;
- lifecycle behavior such as closing and dismissal;
- comparison with other modal libraries.
Those topics deserve to be discussed separately.
So I split the original article into a focused series.
Updated series
1. Stop Treating Every Modal as Boolean State
The conceptual starting point.
It explores the difference between:
boolean state + callbacks
and:
Input -> user interaction -> Promise<Result>
The article focuses on orchestration rather than on any particular library.
Use this article if you want to understand the core idea first.
2. Beyond Promise<any>: Designing a Type-Safe Modal API
A Promise alone is not enough.
If this:
const result = await openModal();
returns any, TypeScript cannot validate the contract between the modal and its caller.
The second article follows the relationship between:
TInput
and:
TResult
through the component, modal definition, registry, and call site.
It also covers discriminated unions and why the type relationship should never silently widen to any.
3. Who Owns Your Modal State? Designing Without a Global Singleton
Once modals can be opened imperatively, a deeper architecture question appears:
Who owns the lifecycle?
This article covers:
- provider-owned state;
- isolated React roots;
- tests;
- Storybook;
- SSR;
- micro-frontends;
- typed imperative registries;
- the trade-off between explicit provider ownership and global convenience.
The point is not that global state is always wrong.
The point is to make lifecycle ownership intentional.
4. Closing a Modal Is Not One Operation
This is the lifecycle article.
Once open() returns a Promise, these operations are no longer equivalent:
resolve
dismiss
remove
animate
unmount
The article covers:
-
close(result); - lifecycle dismissal;
-
ModalDismissError; - instance handles;
- one-time settlement;
- provider teardown;
- exit animations;
- why Promise settlement and visual removal should be separate concerns.
What changed in the library
The current 0.2.x architecture differs from the version described in the original article in a few important ways.
React is the peer dependency
Installation is now:
npm install @okyrychenko-dev/react-modal-manager
Consumers do not need to install Zustand as a peer dependency.
Dismissal is not the same as returning a domain result
A modal can complete explicitly:
close({
status: "cancelled",
});
That is a valid TResult.
But an external lifecycle dismissal:
handle.dismiss();
rejects the pending operation with ModalDismissError.
This distinction lets the library separate:
successful completion with TResult
from:
lifecycle interruption
Provider ownership is an explicit contract
Each ModalProvider owns an independent lifecycle.
There is no process-wide lifecycle singleton shared by every provider.
Imperative access remains typed
For code outside React, a typed registry can be bound to a provider:
export const modals = createModalRegistry({
renameReport: createModal({
component: RenameReportModal,
}),
});
and then:
<ModalProvider registry={modals}>
<App />
</ModalProvider>
The registry provides imperative access without turning lifecycle state itself into a global singleton.
The idea that survived the rewrite
Although the implementation evolved, the central idea did not.
Some modals are just UI state.
For those, this is perfectly fine:
const [open, setOpen] = useState(false);
Other modals participate directly in business flows.
They receive meaningful input.
They produce meaningful outcomes.
For that second category, I find this model more useful:
Input
↓
Modal interaction
↓
Promise<Result>
than treating the entire interaction as nothing more than:
isOpen: boolean
That is the idea explored in much more depth in the updated series.
Start here
If you want the current version of the argument, start with:
Stop Treating Every Modal as Boolean State
Then continue with:
- Beyond
Promise<any>: Designing a Type-Safe Modal API - Who Owns Your Modal State? Designing Without a Global Singleton
- Closing a Modal Is Not One Operation
If you like the approach, drop a ⭐️ on the GitHub repo and let me know what you think in the comments! 👇
Top comments (0)