Skip to content

About

Type-safe promise-based modal manager for React — await modal results, isolate state per provider, and bring your own UI.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Repository files navigation

@okyrychenko-dev/react-modal-manager

npm version npm downloads License: MIT

Open a dialog, await a typed result, and continue your application flow. Input and result types follow the modal definition or registry key; each ModalProvider owns an independent lifecycle.

Use this for repeated selection, editing and confirmation flows. A few local windows may only need component state. The core manages lifecycle; your dialog primitives supply the shell, portal, styling and accessibility policy.

Installation

pnpm add @okyrychenko-dev/react-modal-manager

React 18 or 19 is the only peer dependency. Examples below use guards from @okyrychenko-dev/type-utils; add it as a direct dependency when copying those imports:

pnpm add @okyrychenko-dev/type-utils

Start with a complete dialog

Use the complete plain React + Radix recipe in an existing React application. It includes installation, styling, confirmation content, renderer, Provider and caller, with no Next.js runtime required.

For a larger runnable application, use the Next.js + Radix example. Its setup guide installs the packed library and starts a complete example with a portal, overlay, focus containment, focus restoration, a custom confirmation surface and handled outcomes. It also covers handle dismissal, launcher cleanup and reopening. Radix and Next.js are example dependencies, not core dependencies.

The application combines these pieces:

  • DialogRenderer owns the Radix root, portal and overlay; DialogSurface supplies the shared content surface and focus restoration.
  • ConfirmationContent composes DialogSurface with title, description, confirmation policy and typed Confirm/Cancel results.
  • DialogExample installs both on one ModalProvider.

Inside that configured Provider, a caller can await the inferred confirmation result:

"use client";

import {
  ModalDismissError,
  useModalManager,
} from "@okyrychenko-dev/react-modal-manager";
import { isInstanceOf } from "@okyrychenko-dev/type-utils";
import { useState } from "react";
import type { ReactNode } from "react";

export function ConfirmationButton(): ReactNode {
  const modal = useModalManager();
  const [outcome, setOutcome] = useState("Ready");

  async function handleClick(): Promise<void> {
    try {
      const result = await modal.confirm({
        title: "Delete report?",
        description: "This demo does not change your data.",
      });
      setOutcome(
        result.confirmed ? "Confirmed" : `Cancelled: ${result.reason}`,
      );
    } catch (error: unknown) {
      if (isInstanceOf(error, ModalDismissError)) {
        setOutcome(`Dismissed: ${error.reason}`);
      } else {
        setOutcome("Failed");
        console.error(error);
      }
    }
  }

  return (
    <>
      <button onClick={() => void handleClick()}>Open confirmation</button>
      <p role="status">{outcome}</p>
    </>
  );
}

result.confirmed narrows the result; the negative branch exposes result.reason. This checked caller reports an outcome without modifying data. For application work, perform the action only in the confirmed branch and keep it inside the same error-handling boundary. A caller's own unmount does not cancel a Provider-owned operation; use explicit launcher ownership when the caller owns that lifetime.

Outcomes

Action Promise outcome
Confirm Resolves { confirmed: true }
Cancel in the Radix example or built-in confirmation Resolves { confirmed: false, reason: "cancel" }
Escape or outside interaction in the Radix example (when dismissible) Rejects with ModalDismissError, reason "dismiss"
Escape or Dismiss button in built-in reference confirmation Resolves { confirmed: false, reason: "dismiss" } when dismissible
handle.dismiss() or manager/registry dismiss(id) Rejects with ModalDismissError, default reason "dismiss"
closeAll() Rejects pending operations with reason "close-all"
Provider unmount Rejects pending operations with reason "provider-unmount"
Custom content close(result) / reject(error) Resolves the typed result / rejects; non-Error rejections become ModalRejectError

For custom dialogs, the component chooses whether Cancel returns a result or dismisses. The result settles once, immediately; closeDelayMs can keep a closing instance rendered afterward. Use await handle.whenRemoved() to wait for lifecycle removal after that delay or provider disposal. This does not await a React DOM commit or a renderer's animation callback. See typed flows and cleanup.

Reference confirmation

A bare <ModalProvider> uses the bundled unstyled reference confirmation. It has basic dialog/focus behavior but provides no overlay, portal, background inertness or pointer isolation. It is useful for inspecting the contract; use the complete Radix path above for application integration. Do not put the built-in confirmation inside a renderer that already supplies dialog semantics; compose exactly one dialog content surface with the Radix renderer, as in the example.

Custom flows and scope

Define a component with ModalComponentProps<TInput, TResult>, create its definition with createModal(), then call modal.open(definition, input). The returned ModalHandle<TResult> is awaitable and exposes instanceId, dismiss() and whenRemoved(). See the typed flow guide and checked adoption examples.

Keep the returned handle for deferred cancellation. Use a separate createModalRegistry() per independent scope when opening from commands outside React. Ordinary descendant useEffect callbacks can open through their registry on mount; layout effects have no readiness guarantee. Activity hiding ends pending operations with provider-unmount; revealing permits new operations. A shared registry routes to its latest attached Provider; an instance ID belongs only to its lifecycle. handle.dismiss() retains the original owner even after another Provider mounts. Run the two-scope example.

The current checkout exports useOwnedModalHandles() for opt-in launcher ownership. owner.track(handle) preserves the typed result, forgets settled handles and dismisses pending handles during cleanup. Capture const isActive = owner.captureLifetime() before starting async work and check it before updating local state or opening the next step. Keep the original handle for individual dismissal and removal waiting; the tracked promise has no handle members. This hook is unreleased and is not available in the pinned npm 0.2.0 baseline.

ModalProvider renders its viewport automatically. Use renderer for a shell or portal; adding another viewport renders the same instances twice. Modal content receives context from the Provider's rendering location, so pass launcher-only data as typed input or place the relevant context above the Provider.

Guides and verification

From a repository checkout, pnpm run check:examples builds the public package and checks the consumer snippets. pnpm run preview:nextjs starts a temporary production example against the current packed library and prints its URL; Ctrl+C stops it and removes the copy. pnpm run test:nextjs:unit runs the Next.js example's own typechecks and ownership tests in an isolated packed installation. pnpm run test:nextjs also lints that example, builds production output and exercises Chromium. pnpm run build followed by pnpm run storybook opens the interactive examples; pnpm run test:storybook runs their browser checks. Browser commands require Playwright Chromium (pnpm exec playwright install chromium). These checks do not establish full accessibility certification or support for every framework/browser combination.

License

MIT © Oleksii Kyrychenko

About

Type-safe promise-based modal manager for React — await modal results, isolate state per provider, and bring your own UI.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages