DEV Community

Cover image for Why modal.open() should return Promise<TResult>, not Promise<any>
Oleksii Kyrychenko
Oleksii Kyrychenko

Posted on

Why modal.open() should return Promise<TResult>, not Promise<any>

How treating modals as typed async operations eliminates boolean state, callback chains, and runtime surprises in React apps.

React applications often treat modals as UI details.

A boolean flag. A conditional render. An onClose callback.

That works fine for one dialog.

But real products have modals that are actually business flows:

  • confirm this destructive action
  • rename this entity and return the new name
  • pick a date range and apply it
  • resolve a conflict before continuing
  • complete a wizard step before the next one unlocks

These flows need more than a boolean.

They need typed input, typed output, and a way to await the result — just like any other async operation in your app.

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

That is the idea behind:

npm install @okyrychenko-dev/react-modal-manager zustand
Enter fullscreen mode Exit fullscreen mode

A modal lifecycle manager. Not a component. Not a design system.
A typed async contract between your app logic and your dialog UI.


The problem with traditional modal state

In most React apps, modal state starts locally:

function ReportsPage() {
  const [isRenameOpen, setIsRenameOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsRenameOpen(true)}>Rename</button>
      {isRenameOpen && (
        <RenameModal onClose={() => setIsRenameOpen(false)} />
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

And then real requirements arrive:

const [isRenameOpen, setIsRenameOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isShareOpen, setIsShareOpen] = useState(false);

const [renameTarget, setRenameTarget] = useState<Report | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Report | null>(null);
const [shareTarget, setShareTarget] = useState<Report | null>(null);
Enter fullscreen mode Exit fullscreen mode

The UI is not the problem.

The orchestration is:

  • Where does the modal state live?
  • How do we pass typed input into it?
  • How do we get a typed result back?
  • How do we open it from a command palette, keyboard shortcut, or service?
  • How do we avoid leaking state between tests?

The Promise<any> problem

Several modal libraries already have a promise-based API. For example, @ebay/nice-modal-react is mature, popular, and widely used.

But the result type is often weak:

// nice-modal-react
const result = await NiceModal.show("rename-report", data);
// result is: any

result.nme;       // ✅ TypeScript is silent — typo undetected
result.whatever;  // ✅ TypeScript is silent — doesn't exist
result.name;      // ✅ Fine — but compiler can't help you verify it
Enter fullscreen mode Exit fullscreen mode

When result is any, TypeScript cannot verify the contract between the caller and the modal.

With a typed modal definition, the modal becomes the contract:

// @okyrychenko-dev/react-modal-manager
const result = await modal.open(renameReportModal, data);
// result is: { status: "renamed"; name: string } | { status: "cancelled" }

result.nme;       // ❌ Property 'nme' does not exist on type...
result.whatever;  // ❌ Property 'whatever' does not exist on type...
result.name;      // ✅ Only available after narrowing result.status === "renamed"
Enter fullscreen mode Exit fullscreen mode

The difference is not cosmetic. In long-lived codebases, untyped modal results are a source of silent runtime bugs and refactoring accidents.


Design goals

The library was built around a few constraints:

  • modal input and result should be inferred by TypeScript
  • modal state should be scoped to a provider, not leaked globally
  • the UI layer should stay replaceable
  • opening a modal should work both inside and outside React components
  • closing, dismissing, and rejecting should be explicit lifecycle outcomes

Basic usage

Wrap your app with ModalProvider, then call useModalManager() from any descendant:

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

function App() {
  return (
    <ModalProvider>
      <ReportsPage />
    </ModalProvider>
  );
}

function DeleteButton() {
  const modal = useModalManager();

  async function handleDelete() {
    const { confirmed } = await modal.confirm({
      title: "Delete report?",
      description: "This action cannot be undone.",
      confirmText: "Delete",
      variant: "danger",
    });

    if (!confirmed) return;

    await deleteReport();
  }

  return <button onClick={handleDelete}>Delete</button>;
}
Enter fullscreen mode Exit fullscreen mode

No lifted state. No callback chains. The flow reads sequentially.


Defining a typed modal

A modal is a React component with typed input and typed result:

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

interface RenameReportInput {
  reportId: string;
  currentName: string;
}

type RenameReportResult =
  | { status: "renamed"; name: string }
  | { status: "cancelled" };

function RenameReportModal({
  input,
  close,
}: ModalComponentProps<RenameReportInput, RenameReportResult>) {
  const [name, setName] = useState(input.currentName);

  return (
    <dialog open>
      <h2>Rename report</h2>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={() => close({ status: "cancelled" })}>Cancel</button>
      <button onClick={() => close({ status: "renamed", name })}>Rename</button>
    </dialog>
  );
}

export const renameReportModal = createModal<RenameReportInput, RenameReportResult>({
  component: RenameReportModal,
});
Enter fullscreen mode Exit fullscreen mode

Now every call site gets full inference:

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

TypeScript knows what input the modal requires, what shape can be passed to close(), and how to narrow the result. If you rename a field in RenameReportResult, every call site breaks at compile time — not at runtime.


The open handle

modal.open() returns a ModalHandle — a Promise with two extra properties:

const handle = modal.open(renameReportModal, {
  reportId: report.id,
  currentName: report.name,
});

// Identify this specific instance
console.log(handle.instanceId);

// Dismiss it from outside the modal component — from a timeout, a shortcut, another action
handle.dismiss();

// Still a regular Promise
const result = await handle;
Enter fullscreen mode Exit fullscreen mode

This is useful when the caller needs to control the lifetime of the modal independently of the result — for example, closing a progress dialog when a background job finishes.


Opening modals from non-React code

Sometimes a modal needs to open from outside the React tree: a keyboard shortcut handler, a command palette, a domain service, a Redux thunk.

createModalRegistry creates a typed registry that can be called from anywhere:

// modals.ts
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
// App.tsx — bind it to the provider
<ModalProvider registry={modals}>
  <App />
</ModalProvider>
Enter fullscreen mode Exit fullscreen mode
// keyboard-shortcuts.ts — call it from anywhere
document.addEventListener("keydown", (e) => {
  if (e.key === "F2" && currentReport) {
    void modals.open("renameReport", {
      reportId: currentReport.id,
      currentName: currentReport.name,
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

The key is type-checked. The input is inferred from the registered modal. The result is inferred too — no string-based API, no Promise<any>.


Provider-scoped isolation

Modal state is isolated per ModalProvider. Each provider owns its own internal store — no module-level singleton.

<ModalProvider>
  <AdminApp />
</ModalProvider>

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

These two trees do not share modal state. This matters for:

  • tests — each test mounts its own provider, no cross-test leakage
  • Storybook — each story is isolated
  • SSR and Next.js App Router — no shared state between requests
  • micro-frontends — multiple app roots on the same page

Built-in confirm

modal.confirm() ships out of the box:

const { confirmed, reason } = await modal.confirm({
  title: "Discard changes?",
  description: "Your edits will be lost.",
  variant: "warning",
});

if (confirmed) {
  discard();
} else {
  console.log(reason); // "cancel" | "dismiss"
}
Enter fullscreen mode Exit fullscreen mode

The result is a discriminated union — no need to check typeof or cast anything.

The built-in confirm component includes an accessibility baseline:

  • role="dialog" with aria-modal, aria-labelledby, aria-describedby
  • Focus moves to the dialog on open and returns to the trigger on close
  • Tab / Shift+Tab are trapped within the dialog
  • Escape dismisses (unless dismissible: false)
  • danger variant focuses the cancel button by default — an accidental Enter never confirms a destructive action

It ships without styling. Production apps usually swap it for a design system version via confirmModal prop:

const myConfirm = createModal<ConfirmModalParams, ConfirmModalResult>({
  component: MyDesignSystemConfirm,
});

<ModalProvider confirmModal={myConfirm}>
  <App />
</ModalProvider>
Enter fullscreen mode Exit fullscreen mode

UI-agnostic rendering

The library never prescribes how a modal should look. A renderer boundary lets you plug in any design system:

// Tailwind
function AppModalRenderer({ children, modal }: ModalRendererProps) {
  return (
    <div
      data-status={modal.status}
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/50
                 transition-opacity data-[status=closing]:opacity-0"
    >
      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
        {children}
      </div>
    </div>
  );
}

<ModalProvider closeDelayMs={150} renderer={AppModalRenderer}>
  <App />
</ModalProvider>
Enter fullscreen mode Exit fullscreen mode

When closeDelayMs > 0, a closed modal moves to status: "closing" before being removed — your CSS transition runs, then the instance is unmounted. Your promise settles immediately; the visual exit happens independently.

shadcn/ui works the same way. In a production app, wire onOpenChange to dismiss the modal when the user closes the dialog from the UI:

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

Compared to nice-modal-react

@okyrychenko-dev/react-modal-manager nice-modal-react
Result typing Promise<TResult>, fully inferred Promise<any>
State scope Isolated per ModalProvider Centralized provider/global-style modal store
Open from non-React code Typed registry Id-based global-style API
Built-in confirm Typed + accessibility baseline Not the main focus
UI coupling UI-agnostic renderer Integration helpers for specific libs
Maturity New 3+ years, battle-tested, large ecosystem

nice-modal-react is a genuinely good library. If you want a mature modal manager with broad ecosystem usage and id-based convenience, it is a great choice.

This library takes a different trade-off: stricter types and explicit isolation over convenience and ecosystem breadth.


When this is a good fit

  • Your modals return meaningful, typed results
  • You want to await modal flows inline
  • You want TypeScript to catch call-site mismatches at compile time
  • You use Next.js App Router, SSR, Storybook, or micro-frontends
  • You need to open modals from action maps, services, or keyboard shortcuts
  • You want UI-library agnosticism at the core

When it is not

  • You have only one or two simple dialogs — useState is enough
  • Your modals do not return structured results
  • You want a fully styled modal component out of the box
  • You prefer a global string-based API over typed definitions
  • You do not want a Zustand peer dependency

Final thoughts

A modal that asks "Are you sure?" before a destructive action is not a UI detail.

It is a checkpoint in a business flow. It has an outcome. That outcome affects what happens next.

Treating it as a boolean flag — or as Promise<any> — throws away information the compiler could use to keep your code correct.

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

Simple at the call site. Strict at the type level. Isolated at the provider boundary.

That is the bet this library makes: modal flows should be as type-safe as the rest of your application logic.


Try it out:

npm install @okyrychenko-dev/react-modal-manager
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)