DEV Community

Cover image for Who Owns Your Modal State? Designing Without a Global Singleton
Oleksii Kyrychenko
Oleksii Kyrychenko

Posted on

Who Owns Your Modal State? Designing Without a Global Singleton

An imperative API makes modals easy to open from anywhere. The architectural cost appears one level deeper: somewhere, something has to own the lifecycle.

Once a modal can be opened like this:

await modal.open(someModal, input);
Enter fullscreen mode Exit fullscreen mode

the next requirement usually arrives quickly: the same operation should be startable from somewhere that is not the component rendering the modal.

A keyboard shortcut should open a modal.

A command palette should open one.

A test should mount an isolated application subtree.

Storybook stories should not leak state into each other.

A server-rendered application should not accidentally share modal state across roots.

A micro-frontend may have its own modal lifecycle entirely.

At that point, a global singleton becomes a significant architectural decision.

This article explains why react-modal-manager uses provider-owned lifecycle state and a separate typed registry for imperative access.


The singleton is convenient for a reason

The easiest imperative API is often a module singleton:

const modalStore = createStore();

export function openModal(...) {
  modalStore.open(...);
}
Enter fullscreen mode Exit fullscreen mode

Then any code can call:

openModal(...);
Enter fullscreen mode Exit fullscreen mode

This is convenient. There is only one instance. No provider reference needs to be passed around. No dependency injection is necessary. For many applications, this may be acceptable. But a singleton creates an implicit assumption:

every caller in this JavaScript runtime belongs to the same modal lifecycle.

That assumption becomes problematic when the application has multiple independently mounted trees.


Make lifecycle ownership match React ownership

Consider two React roots:

createRoot(adminRoot).render(
  <ModalProvider>
    <AdminApp />
  </ModalProvider>,
);

createRoot(previewRoot).render(
  <ModalProvider>
    <PublicPreview />
  </ModalProvider>,
);
Enter fullscreen mode Exit fullscreen mode

Should a modal opened inside AdminApp automatically belong to PublicPreview?

Usually not.

Those are separate application scopes. So a useful invariant is:

ModalProvider
    ↓ owns
modal lifecycle state
Enter fullscreen mode Exit fullscreen mode

rather than:

JavaScript module
    ↓ owns
modal lifecycle state for every root
Enter fullscreen mode Exit fullscreen mode

In react-modal-manager, each provider owns an independent lifecycle.

<ModalProvider>
  <AdminApp />
</ModalProvider>

<ModalProvider>
  <PublicPreview />
</ModalProvider>
Enter fullscreen mode Exit fullscreen mode

The two trees do not share active modal instances.


Isolation becomes concrete in tests

Global mutable state is one of the easiest ways for tests to influence each other. Imagine test A opens a modal and exits unexpectedly. Test B mounts another component in the same process.

If the lifecycle lives in a module singleton, cleanup becomes part of global test hygiene.

With provider ownership:

render(
  <ModalProvider>
    <Subject />
  </ModalProvider>,
);
Enter fullscreen mode Exit fullscreen mode

the mounted tree owns its own modal state. When that provider goes away, that lifecycle goes away with it.

That does not eliminate all test cleanup concerns, but it makes the state boundary explicit. The test owns the provider. The provider owns the lifecycle.


Storybook benefits from the same boundary

Storybook renders many isolated scenarios over time. A story should not depend on whether another story opened a modal earlier.

Provider scoping makes a story self-contained:

export const Default = {
  render: () => (
    <ModalProvider>
      <Example />
    </ModalProvider>
  ),
};
Enter fullscreen mode Exit fullscreen mode

The same pattern applies to visual test harnesses and component playgrounds.


Micro-frontends expose hidden global coupling

Micro-frontends are an even clearer example. Imagine two separately deployed application areas on one page. Both use the same modal library.

A global singleton means both applications are now coupled through shared lifecycle infrastructure unless additional namespacing is introduced.

With provider-owned state:

<ModalProvider>
  <BillingMicroFrontend />
</ModalProvider>

<ModalProvider>
  <AnalyticsMicroFrontend />
</ModalProvider>
Enter fullscreen mode Exit fullscreen mode

each application area can own its own modal stack and rendering boundary. The ownership model remains local.


Imperative access without global lifecycle state

Provider ownership creates an obvious tension.

Inside React, this is easy:

const modal = useModalManager();
Enter fullscreen mode Exit fullscreen mode

Outside React, there are no hooks.

Examples include:

  • command handlers;
  • keyboard shortcuts;
  • action maps;
  • event buses;
  • framework integration code;
  • non-React services that need to start a user interaction.

A common response is to reintroduce a global manager. But that gives up the isolation we just created. A better approach is to separate two concerns:

Definition / routing
        ≠
Lifecycle ownership
Enter fullscreen mode Exit fullscreen mode

That is where a typed registry becomes useful.


Use a registry as a typed imperative facade

Define the known modals:

import {
  createModal,
  createModalRegistry,
} from "@okyrychenko-dev/react-modal-manager";

export const modals = createModalRegistry({
  renameReport: createModal({
    component: RenameReportModal,
  }),

  deleteReport: createModal({
    component: DeleteReportModal,
  }),
});
Enter fullscreen mode Exit fullscreen mode

Bind the registry to a provider:

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

Now non-React code can use the registry:

export async function renameFromCommand(
  reportId: string,
  currentName: string,
) {
  const result = await modals.open("renameReport", {
    reportId,
    currentName,
  });

  return result;
}
Enter fullscreen mode Exit fullscreen mode

The registry gives imperative code an entry point. The provider still owns the lifecycle. That distinction is important.


A typed registry is more than string routing

A weak registry API might look like this:

open(name: string, payload: unknown): Promise<unknown>
Enter fullscreen mode Exit fullscreen mode

It provides routing, but not much else.

A typed registry can encode:

"renameReport"
      ↓
RenameReportInput
      ↓
RenameReportResult
Enter fullscreen mode Exit fullscreen mode

So this call:

await modals.open("renameReport", {
  reportId: "42",
  currentName: "Q3",
});
Enter fullscreen mode Exit fullscreen mode

can be checked end to end.

The key identifies a definition, and the definition carries its input/result contract.

This lets imperative access remain type-safe without using an unchecked global string API.


Explicit binding makes ownership visible

There is an intentional trade-off here. A globally available singleton can often be called immediately. A provider-bound registry has a lifecycle:

registry definition
      ↓
provider mounts
      ↓
registry is bound to that provider
      ↓
imperative open calls can target that lifecycle
Enter fullscreen mode Exit fullscreen mode

That means the application explicitly decides which provider owns the registry. There is slightly more setup. In exchange, the ownership boundary remains visible.

There is another useful consequence: a registry can have binding semantics without becoming the lifecycle owner itself.

In the current design, a registry may be bound to more than one provider. Those bindings form a LIFO stack: imperative calls target the most recently mounted provider, and fall back to the previous binding when the newer provider unmounts.

That can be useful when routing is intentional.

But if two application areas are meant to be strictly isolated, the clearer design is usually to give them separate registries:

const accountModals = createModalRegistry({
  rename: renameReportModal,
});

const workspaceModals = createModalRegistry({
  rename: renameReportModal,
});

function App() {
  return (
    <>
      <ModalProvider registry={accountModals}>
        <AccountSettings />
      </ModalProvider>

      <ModalProvider registry={workspaceModals}>
        <Workspace />
      </ModalProvider>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

So the rule is not:

one registry equals one provider forever.

It is:

registry routing and lifecycle ownership are separate concepts; use separate registries when you want strict scope boundaries.

Architecture is often about choosing which kinds of convenience should remain implicit.


SSR makes accidental shared state more expensive

Server rendering is another environment where global mutable state deserves extra scrutiny.

The important principle is not "globals are always broken in SSR." The important principle is:

request-specific UI lifecycle state should not accidentally be represented by shared module-level mutable state.

A provider-owned lifecycle naturally maps state to the rendered tree. That makes the boundary easier to reason about in SSR and hydration scenarios.

There is one practical detail for imperative registries: binding happens in a client effect. During server rendering the registry is intentionally not ready, because there is no mounted client lifecycle to receive modal work.

That means code using a registry should treat readiness as part of its runtime contract:

if (modals.isReady()) {
  await modals.open("renameReport", input);
}
Enter fullscreen mode Exit fullscreen mode

or structure the application so imperative modal work starts only after the client provider has mounted.

For Next.js App Router, the provider still belongs on the client side because modal interaction is client behavior.

A simplified boundary looks like:

"use client";

import { ModalProvider } from "@okyrychenko-dev/react-modal-manager";

export function ModalRoot({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ModalProvider>
      {children}
    </ModalProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The server layout can compose that client boundary without making modal lifecycle state itself global.


Keep lifecycle ownership separate from rendering

State ownership and visual rendering are also separate concerns. The lifecycle manager needs to know that a modal instance exists.

It does not necessarily need to prescribe:

  • a portal;
  • an overlay;
  • animation;
  • z-index;
  • design-system markup;
  • Tailwind classes;
  • Material UI components;
  • shadcn/ui components.

A renderer boundary lets the application decide how lifecycle state becomes presentation.

Conceptually:

Modal lifecycle
      ↓
Modal instance
      ↓
Renderer
      ↓
Your design system
Enter fullscreen mode Exit fullscreen mode

For example:

function AppModalRenderer({
  children,
  modal,
}: ModalRendererProps) {
  return (
    <div data-status={modal.status}>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This keeps the modal manager focused on orchestration rather than becoming another dialog component library.


The resulting architecture

The resulting model has several distinct parts:

Modal definition
    │
    │ describes input + result + component
    ↓
ModalProvider
    │
    │ owns lifecycle state
    ↓
Modal instance
    │
    ├── rendered through application renderer
    │
    └── settles Promise<Result>


Typed registry
    │
    │ imperative routing
    ↓
Bound provider lifecycle
Enter fullscreen mode Exit fullscreen mode

Each piece has one primary responsibility.

Modal definition

Describes the interaction contract.

Provider

Owns active instances and their lifecycle.

Renderer

Controls presentation.

Registry

Provides typed imperative routing. The design does not require those concerns to be collapsed into one global manager object.


The trade-off: more explicit setup, fewer implicit owners

Provider ownership is not free.

It introduces questions such as:

  • Which provider should a registry bind to?
  • What happens if imperative code calls before binding?
  • What happens when the provider unmounts with pending modals?
  • Should nested providers be independent?
  • How should pending promises settle during teardown?

Those questions must be answered explicitly.

There is also a subtle ownership question that becomes important in real applications:

If the component that started a modal unmounts, should the modal disappear too?

With provider-owned lifecycle state, the answer is not automatically.

If the provider remains mounted, the modal can legitimately outlive the calling component. This is useful when the operation belongs to a larger application scope.

If the caller itself owns the lifetime, cleanup is a caller policy: keep the returned handles and dismiss the still-pending ones during unmount.

That distinction is important because "who opened the modal?" and "who owns the modal lifecycle?" are not always the same answer.

But making that policy explicit is preferable to leaving it as an accidental consequence of module state. A good abstraction does not eliminate lifecycle. It makes lifecycle visible enough to define.


A singleton can still be the right choice

Not every application needs provider-level isolation. A singleton can be reasonable when:

  • there is exactly one application root;
  • the modal manager is truly application-global;
  • SSR is irrelevant;
  • tests reset global state consistently;
  • typed routing is not a requirement;
  • the simplicity is worth the coupling.

A singleton is not inherently wrong. It simply encodes a specific ownership model: one lifecycle for the whole runtime.

If that matches the application, use it intentionally. If it does not, provider ownership gives the boundary a place in the component tree.


Final thoughts

Once a modal becomes an async application operation, its state becomes more important than a simple isOpen flag. The architecture needs to answer:

Who owns this operation?
Who can start it?
Where is it rendered?
How does it settle?
What happens when its owner disappears?
Enter fullscreen mode Exit fullscreen mode

react-modal-manager answers those questions by separating:

  • typed modal definitions;
  • provider-owned lifecycle state;
  • a replaceable rendering boundary;
  • typed imperative registries.

The important distinction is not "global access vs no global access."

It is:

imperative access without making the lifecycle itself a process-wide singleton.


The next article focuses on the hardest part of the abstraction:

Closing a Modal Is More Complicated Than It Looks — resolve vs dismiss, Promise settlement, external control, provider teardown, and exit animations.

If you like the approach, drop a ⭐️ on the GitHub repo and let me know what you think in the comments! 👇

Top comments (0)