Once
open()returns a Promise, modal lifecycle design becomes observable API behavior: resolve, dismiss, remove, animate, and tear down are no longer interchangeable.
A promise-based modal API looks wonderfully small:
const result = await modal.open(someModal, input);
The complexity is simply hidden below the call site.
What happens when:
- the modal completes successfully?
- the modal explicitly rejects because the operation failed?
- the user presses Escape?
- the caller dismisses the modal programmatically?
- all modals are closed at once?
- the provider unmounts?
- the UI needs a 200 ms exit animation?
- two different pieces of code try to settle the same modal?
Once an interaction is represented by a Promise, these are not merely rendering details.
They are part of the API contract.
close(result) and dismiss() should mean different things
Suppose a rename modal has this result:
type RenameResult =
| {
status: "renamed";
name: string;
}
| {
status: "cancelled";
};
The modal can explicitly complete with:
close({
status: "renamed",
name,
});
or:
close({
status: "cancelled",
});
Both values are valid domain results.
From the caller's point of view:
const result = await modal.open(renameModal, input);
resolves with a RenameResult.
But now imagine the caller does this:
const handle = modal.open(renameModal, input);
handle.dismiss();
Should that resolve to:
{ status: "cancelled" }
automatically?
Not necessarily.
The library cannot assume that every modal has a cancelled variant.
Even if it does, a programmatic lifecycle interruption may mean something different from the user intentionally selecting "Cancel."
That suggests an important separation:
close(result)
↓
successful completion with TResult
dismiss()
↓
lifecycle interruption
In react-modal-manager, dismissal rejects the pending operation with ModalDismissError.
Why lifecycle dismissal belongs on the rejection channel
Consider a modal that must return a selected account:
type AccountPickerResult = {
accountId: string;
};
There is no natural fake value to return if the modal disappears.
Resolving with undefined weakens the type:
Promise<AccountPickerResult | undefined>
Adding a cancellation variant changes the domain contract even if cancellation is not conceptually a result.
Silently leaving the Promise pending is worse.
Rejection provides a separate channel:
try {
const result = await modal.open(accountPickerModal, input);
await selectAccount(result.accountId);
} catch (error) {
if (error instanceof ModalDismissError) {
return; // lifecycle interruption
}
throw error;
}
The successful path stays:
Promise<TResult>
while interruption remains explicit.
reject(error) is a third semantic channel
Once close(result) and dismiss() are separate, there is still another case:
the modal operation itself failed.
For that, a lifecycle API can expose:
reject(error);
This is different from both successful completion and abandonment:
close(result)
-> resolve with TResult
reject(error)
-> reject because the operation failed
dismiss(reason)
-> reject because the interaction was abandoned
In react-modal-manager, non-Error rejection values are normalized to ModalRejectError, while lifecycle dismissal uses ModalDismissError.
The distinction matters because callers may want to react differently:
try {
const result = await modal.open(importModal, input);
await processImportResult(result);
} catch (error) {
if (error instanceof ModalDismissError) {
return;
}
reportFailure(error);
}
A single "close" operation cannot express all three semantics without smuggling meaning into arbitrary result values.
Domain cancellation can still be a valid result
This does not mean "Cancel" must always reject.
If cancellation is a meaningful outcome of the interaction, model it:
type RenameResult =
| {
status: "renamed";
name: string;
}
| {
status: "cancelled";
};
Then the Cancel button can call:
close({
status: "cancelled",
});
The key distinction is ownership of the decision.
The modal intentionally produced a valid result.
That is different from the lifecycle being interrupted externally.
The built-in confirm() is a good example.
Its normal UI actions resolve with typed results:
{ confirmed: true }
{ confirmed: false, reason: "cancel" }
{ confirmed: false, reason: "dismiss" }
So pressing Escape in the built-in confirmation UI resolves as:
{
confirmed: false,
reason: "dismiss",
}
That UI action intentionally calls close(result).
By contrast, these manager-level operations reject with ModalDismissError:
handle.dismiss();
modal.dismiss(instanceId);
modal.closeAll();
Provider teardown does the same for still-pending operations.
The important rule is therefore not:
Escape always rejects.
It is:
a component may model a user action as a valid domain result, while lifecycle-level interruption remains a rejection.
A Promise can still expose instance control
Sometimes awaiting the result is enough:
const result = await modal.open(renameModal, input);
Sometimes the caller needs to control the specific instance later.
For example:
const handle = modal.open(progressModal, {
title: "Generating report...",
});
// Observe the handle's rejection before programmatic dismissal.
const completion = handle.catch((error: unknown) => {
if (!(error instanceof ModalDismissError)) {
reportFailure(error);
}
});
try {
await generateReport();
} finally {
handle.dismiss();
await completion;
}
A useful API can therefore return a Promise-like handle:
const handle = modal.open(renameModal, input);
console.log(handle.instanceId);
try {
// The caller may dismiss this instance from another event.
const result = await handle;
processRenameResult(result);
} catch (error) {
if (!(error instanceof ModalDismissError)) {
throw error;
}
}
// Elsewhere, while the handle is still pending:
// handle.dismiss();
This preserves the ergonomic async API while still exposing instance-level lifecycle control.
Conceptually:
interface ModalHandle<TResult>
extends Promise<TResult> {
readonly instanceId: string;
dismiss(): void;
}
The exact implementation can vary, but the design goal is useful:
do not force the caller to choose between awaiting the operation and controlling its instance.
Every instance gets one logical settlement
Promise settlement is idempotent at the JavaScript level, but lifecycle state still needs clear rules.
Imagine:
- the user clicks "Save";
-
close(result)runs; - an Escape event fires during the same frame;
-
dismiss()also runs.
The abstraction must guarantee a single logical outcome.
A modal instance should move from pending to settled once.
Conceptually:
┌─ resolve(result)
pending ──┤
└─ reject(dismissal)
after settlement:
additional settlement attempts do nothing
This matters because callbacks around settlement can affect more than the Promise:
- removing state;
- starting animations;
- releasing registry references;
- restoring focus;
- logging;
- triggering caller code.
The lifecycle therefore needs its own once-only transition, not merely reliance on JavaScript's Promise behavior.
In the current design, the first close(result), reject(error), or dismiss(reason) wins. Later settlement attempts for that instance are ignored.
"Promises ignore the second resolve" is not a complete lifecycle design.
Promise settlement and visual removal run on different clocks
Exit animation introduces another subtlety.
Suppose the user clicks Save.
The application should be able to continue immediately:
const result = await modal.open(...);
navigateToNextStep(result);
But visually, the modal may need to fade out for 150 ms.
If Promise settlement waits for the animation, application logic becomes coupled to presentation timing.
If the modal disappears immediately, the animation cannot run.
The solution is to separate:
logical settlement
from:
visual removal
For example:
open
↓
close(result)
↓
Promise resolves immediately
↓
status = "closing"
↓
exit transition
↓
instance removed
In react-modal-manager, closeDelayMs supports this separation.
The caller gets the result without waiting for CSS.
The renderer gets time to animate the closing instance.
Expose semantic status; let the renderer decide the animation
A renderer can use lifecycle status:
function AppModalRenderer({
children,
modal,
}: ModalRendererProps) {
return (
<div
data-status={modal.status}
className="modal-shell"
>
{children}
</div>
);
}
Then CSS can react to:
.modal-shell[data-status="closing"] {
opacity: 0;
}
The modal manager does not need to know which animation library is used.
It only needs to expose enough lifecycle information for the presentation layer to respond.
This is a useful boundary:
manager:
semantic lifecycle
renderer:
visual lifecycle
Provider teardown must terminate pending operations
Provider-owned state creates another lifecycle edge case.
Consider:
<ModalProvider>
<Feature />
</ModalProvider>
Feature opens a modal:
const pending = modal.open(someModal, input);
Then the entire provider unmounts before the modal completes.
What should happen to pending?
Leaving it unresolved would create a dangling Promise.
Resolving it with a fabricated result would violate the result contract.
A lifecycle dismissal is the natural outcome.
In the current react-modal-manager contract, provider teardown rejects pending operations with ModalDismissError.
The same principle applies to operations such as closing all active instances.
Every pending operation must have a defined termination path.
Caller unmount is not provider unmount
A subtler case appears when the component that opened a modal disappears while the provider remains mounted.
Consider:
<ModalProvider>
<Page />
</ModalProvider>
Page opens a modal, then Page unmounts because navigation changes.
Should the modal be dismissed automatically?
Not necessarily.
The provider owns the lifecycle, so the modal can legitimately outlive the component that initiated it.
That is useful for operations whose lifetime belongs to a broader application scope.
But sometimes the caller does own the interaction.
In that case, cleanup should be explicit.
And if the caller can launch more than one modal concurrently, tracking only the latest handle is not enough.
function RenameLauncher() {
const modal = useModalManager();
const pending = useRef(
new Set<ModalHandle<RenameReportResult>>(),
);
useEffect(() => {
const handles = pending.current;
return () => {
for (const handle of handles) {
handle.dismiss();
}
handles.clear();
};
}, []);
async function handleClick() {
const handle = modal.open(renameReportModal, {
reportId: "report-1",
currentName: "Draft",
});
pending.current.add(handle);
try {
const result = await handle;
if (result.status === "renamed") {
await renameReport({
reportId: "report-1",
name: result.name,
});
}
} catch (error) {
if (!(error instanceof ModalDismissError)) {
throw error;
}
} finally {
pending.current.delete(handle);
}
}
return <button onClick={handleClick}>Rename</button>;
}
Why a Set?
Because this is incorrect when repeated launches are possible:
pending.current = modal.open(...);
A second click replaces the first handle. Cleanup would dismiss only the latest modal and leave the earlier Promise pending.
Tracking every active handle makes the ownership policy match the actual concurrency model.
This is an important architectural point:
caller cleanup is optional policy, not hidden library behavior.
If the operation should survive the caller, do not dismiss it in caller cleanup. If the caller owns the operation, track and terminate every pending instance it created.
Dismiss an instance, not merely a modal definition
Once multiple instances can exist, "close modal X" is not always precise enough.
The same modal definition might be opened twice:
const first = modal.open(renameModal, firstInput);
const second = modal.open(renameModal, secondInput);
A definition identifies a kind of modal.
It does not necessarily identify an active instance.
That is why an instance identifier matters:
first.instanceId;
second.instanceId;
The handle can remain bound to exactly the lifecycle that created it:
first.dismiss();
This avoids ambiguity and becomes especially useful for:
- stacked modals;
- concurrent operations;
- progress dialogs;
- timeout-driven dismissal;
- external commands.
Bridge design-system close events into lifecycle semantics
A design-system dialog can have its own closing behavior.
For example, a user clicks an overlay and the underlying dialog component reports:
onOpenChange(false)
That needs to reach the lifecycle manager.
A renderer can bridge the event:
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>
);
}
The design system detects presentation-level dismissal.
The modal manager converts that event into lifecycle semantics.
The pending Promise then terminates consistently with other dismissal paths.
Integration note: This is a conceptual renderer sketch, not a drop-in shadcn/ui integration. A provider-wide renderer wraps both custom modals and built-in confirmation content. If a child already renders its own <Dialog> or <dialog>, wrapping it in DialogContent can create nested dialog semantics and conflicting focus management. Use content-only modal components with this renderer and supply a compatible custom confirmModal when needed. The renderer also needs an explicit strategy for Radix exit animations and closeDelayMs; closing immediately via open={false} does not by itself prove that the visual exit matches the manager's removal delay.
Strict Mode changes how teardown must be implemented
React 18 and 19 development Strict Mode can replay effects.
That creates a trap for provider-owned lifecycle state.
A naïve cleanup implementation might interpret the development-only effect cleanup as final provider disposal and reject every pending modal — even though React immediately remounts the same owner as part of replay.
The lifecycle therefore needs to distinguish:
temporary Strict Mode cleanup
from:
final provider disposal
The current implementation defers final teardown and cancels it when the same lifecycle owner immediately remounts.
This is not merely a test convenience. It preserves the public guarantee that provider teardown terminates pending work without making development Strict Mode behave differently from a real mounted application.
The larger lesson is useful beyond modals:
when asynchronous resources are owned by a React provider, effect cleanup is not always equivalent to permanent ownership loss.
If callers can observe it, it is part of the API
It is tempting to treat all of this as internal implementation detail.
But callers observe the consequences.
They observe whether:
await modal.open(...)
resolves, rejects, or never settles.
They observe whether programmatic dismissal is possible.
They observe whether a closing animation delays business logic.
They observe what happens when the owning provider disappears.
Therefore lifecycle semantics are part of the contract, even if internal data structures are not.
Think in two state machines
A simplified model could look like:
close(result)
┌───────────────► settled: resolved
│
open ─► pending
│
└───────────────► settled: rejected
dismiss()
visual state:
open
↓
closing (optional)
↓
removed
The two models interact, but they answer different questions.
Promise state answers:
Has the operation completed?
Visual state answers:
Should the modal still be rendered?
That separation is what makes exit animations possible without delaying application control flow.
Final thoughts
The first version of a promise-based modal API often focuses on this:
const result = await openModal();
The difficult part comes next.
You have to define:
- what counts as a valid result;
- what counts as operational failure;
- what counts as lifecycle dismissal;
- whether a particular Cancel/Escape action resolves or rejects;
- who can dismiss an instance;
- whether the caller or provider owns its lifetime;
- how concurrent instances are tracked;
- what provider teardown does;
- how Strict Mode replay interacts with cleanup;
- whether promises settle before or after exit animations;
- how repeated settlement attempts are handled.
Those decisions determine whether the abstraction remains predictable under real application pressure.
A modal is not finished merely because it disappeared from the screen.
Its operation and its visual instance each have a lifecycle.
Once those lifecycles are designed separately, await modal.open(...) can stay simple without making the underlying semantics vague.
If you like the approach, drop a ⭐️ on the GitHub repo and let me know what you think in the comments! 👇
Top comments (0)