Some modals are UI state. Others are asynchronous business operations. Modeling the second group as
Input -> Promise<Result>can make React flows dramatically easier to compose.
Most React modals begin with a boolean. That is often the right starting point. The trouble begins when a dialog stops being merely visible UI and starts producing a value that the rest of the application depends on.
At that point, isOpen tells us almost nothing about the operation we are actually performing.
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Rename</button>
{isOpen && (
<RenameModal onClose={() => setIsOpen(false)} />
)}
</>
);
For a simple informational dialog, this can be all you need.But many production dialogs are not just presentation. Real applications contain modal flows such as:
- confirm a destructive action;
- rename an entity and continue with the new value;
- select a date range;
- choose one of several conflict-resolution strategies;
- complete a wizard step before the application can continue.
At that point the modal has something that a boolean does not express:
an outcome.
And once a UI interaction has an outcome, it is useful to ask whether we should model it as an operation instead of a flag.
Where the boolean model starts to leak
A common implementation grows like this:
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<Report | null>(null);
function handleRenameClick(report: Report) {
setRenameTarget(report);
setIsRenameOpen(true);
}
function handleRename(name: string) {
if (!renameTarget) {
return;
}
renameReport({
id: renameTarget.id,
name,
});
setIsRenameOpen(false);
setRenameTarget(null);
}
Then requirements grow.
Maybe the operation is async.
Maybe it can be cancelled.
Maybe opening the modal originates from a command palette instead of the component that renders it.
Maybe two independent application roots can show dialogs.
None of those requirements are impossible with local state.
But the orchestration becomes increasingly distributed.
The caller starts an interaction in one place.
The modal emits callbacks somewhere else.
Another piece of state stores the target.
A different callback continues the business operation.
The important question is not:
How do I show a modal?
It is:
How does the calling code receive the outcome of this interaction?
Model the interaction, not just its visibility
Consider what the modal is doing from the caller's point of view.
It receives input:
{
reportId: string;
currentName: string;
}
The user interacts with UI. Then the interaction produces an outcome:
type RenameResult =
| { status: "renamed"; name: string }
| { status: "cancelled" };
Conceptually, the operation is:
Input
↓
[ user interaction ]
↓
Result
That looks remarkably similar to an asynchronous function.
Input -> Promise<Result>
So instead of making the calling component coordinate the modal through state and callbacks, we can make the orchestration read like this:
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 UI interaction still exists.
The state still exists somewhere.
What changes is where the orchestration lives.
The business flow stays at the call site.
isOpen is implementation state, not the business contract
This distinction is important. A modal implementation may absolutely need to know whether an instance is mounted, open, closing, or removed. Those are lifecycle details.
But the caller usually does not care about:
isRenameModalOpen === true
The caller cares about:
const result = await rename();
In other words:
UI implementation
open / closing / removed
Application flow
input -> result
These are different layers of abstraction. Treating them as the same thing is one reason modal logic tends to spread through large components.
Keep the business flow at the call site
Consider a delete flow.
A callback-oriented implementation might look like this:
function handleDeleteClick(report: Report) {
setPendingDelete(report);
setDeleteModalOpen(true);
}
async function handleDeleteConfirmed() {
if (!pendingDelete) {
return;
}
await deleteReport(pendingDelete.id);
setDeleteModalOpen(false);
setPendingDelete(null);
refreshReports();
}
With an asynchronous modal contract:
async function handleDeleteClick(report: Report) {
const result = await modal.confirm({
title: "Delete report?",
description: "This action cannot be undone.",
variant: "danger",
});
if (!result.confirmed) {
return;
}
await deleteReport(report.id);
refreshReports();
}
The point is not that every callback is bad or every modal needs a manager.
The important difference is what the second version keeps together:
- the control flow is local;
- the entity being operated on remains in lexical scope;
- the next operation starts where the result is consumed;
- no temporary target state is required;
- the sequence can be read from top to bottom.
The modal becomes another asynchronous boundary in the application instead of a separate state machine the caller has to coordinate manually.
The real abstraction is Modal<Input, Result>
This is not primarily about making callbacks look cleaner. The more useful design change is that the modal becomes a contract.
For example:
type RenameInput = {
reportId: string;
currentName: string;
};
type RenameResult =
| { status: "renamed"; name: string }
| { status: "cancelled" };
That contract describes both sides of the interaction.
The caller must provide valid input.
The modal must produce a valid result.
The caller receives exactly that result.
The resulting abstraction is closer to:
Modal<Input, Result>
than to:
boolean
Cancellation is part of the domain
One of the benefits of this model is that cancellation can become explicit.
Sometimes cancellation is a valid result:
type RenameResult =
| { status: "renamed"; name: string }
| { status: "cancelled" };
Sometimes an external dismissal should be treated differently from a domain result.
For example:
- the user presses Escape;
- another part of the application dismisses the modal;
- the provider is unmounted;
- the application closes all pending modal instances.
A modal manager can model those as lifecycle-level dismissal rather than pretending they are successful domain results.
That distinction becomes particularly useful once modal orchestration grows beyond simple dialogs.
I will cover lifecycle semantics in a later article in this series.
Do not abstract every dialog
Not every modal needs this abstraction.
If your component owns a dialog and all it needs is:
const [open, setOpen] = useState(false);
keep it.
An abstraction becomes useful when the interaction itself matters to application logic.
Good candidates include dialogs that:
- return structured data;
- participate in a multi-step operation;
- need to be opened from different parts of the application;
- are reused across many call sites;
- need consistent lifecycle semantics.
A modal manager earns its place only when it removes orchestration complexity. useState should remain the default for dialogs that do not need a richer contract.
What this looks like as an API
I built @okyrychenko-dev/react-modal-manager around this model.
The core call site is intentionally small:
const result = await modal.open(renameReportModal, {
reportId: report.id,
currentName: report.name,
});
A modal definition couples its input and result types:
Modal<Input, Result>
So the call behaves conceptually like:
open(
modal: Modal<Input, Result>,
input: Input,
): Promise<Result>
The actual API also has lifecycle semantics for dismissal, instance handles, provider ownership, typed registries, and delayed visual removal.
But those features follow from the same starting point:
A modal interaction can be modeled as an asynchronous operation with input and an outcome.
Final thoughts
React makes boolean UI state easy, and that is a feature. The mistake is assuming that visibility is always the most useful abstraction for the caller. Some dialogs are presentation state. Others are application operations with meaningful outcomes.
For the second category, this model:
Input
↓
Modal interaction
↓
Promise<Result>
can provide a much cleaner boundary than:
boolean
+
temporary state
+
callbacks
+
continuation logic elsewhere
Once you see a modal as an operation rather than a flag, await modal.open(...) stops looking like a convenience API.
It becomes a natural representation of the interaction.
The next article in this series goes one level deeper:
Designing a Type-Safe Modal API with TypeScript — how Modal<TInput, TResult> can propagate type information from the modal component all the way to the caller without Promise<any>.
If you like the approach, drop a ⭐️ on the GitHub repo and let me know what you think in the comments! 👇
Top comments (0)