DEV Community

Cover image for Why modal.open() Should Return Promise<TResult>, Not Promise<any> [Original Version]
Oleksii Kyrychenko
Oleksii Kyrychenko

Posted on Edited on

Why modal.open() Should Return Promise<TResult>, Not Promise<any> [Original Version]

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:

Stop Treating Every Modal as Boolean State

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);
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

The important part is not only the Promise.

It is the contract:

Modal<TInput, TResult>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and:

Input -> user interaction -> Promise<Result>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

returns any, TypeScript cannot validate the contract between the modal and its caller.

The second article follows the relationship between:

TInput
Enter fullscreen mode Exit fullscreen mode

and:

TResult
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

That is a valid TResult.

But an external lifecycle dismissal:

handle.dismiss();
Enter fullscreen mode Exit fullscreen mode

rejects the pending operation with ModalDismissError.

This distinction lets the library separate:

successful completion with TResult
Enter fullscreen mode Exit fullscreen mode

from:

lifecycle interruption
Enter fullscreen mode Exit fullscreen mode

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,
  }),
});
Enter fullscreen mode Exit fullscreen mode

and then:

<ModalProvider registry={modals}>
  <App />
</ModalProvider>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

than treating the entire interaction as nothing more than:

isOpen: boolean
Enter fullscreen mode Exit fullscreen mode

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:

  1. Beyond Promise<any>: Designing a Type-Safe Modal API
  2. Who Owns Your Modal State? Designing Without a Global Singleton
  3. 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)