@easy-shadcn/command-modal is a powerful imperative modal management library that allows you to control modals through simple function calls. Built with TypeScript and designed for flexibility, it works seamlessly with any UI library.
Originally inspired by @ebay/nice-modal-react, this library provides an enhanced developer experience with full type safety and configurable modal adapters.
To avoid leaked pending promises (e.g. await modal.show() hanging forever
after the modal was dismissed without an explicit resolve), the library
settles outstanding promises on teardown paths:
hide(modal) settles any pending show() promise with undefined before
changing visibility. Your resolve() / reject() calls still win if they
happened first (promises can only settle once).
remove(modal) settles any pending hide() promise with undefined.
When a ModalDef unmounts or unregister() fires with pending promises,
both are settled with undefined.
In practice this means: if you await modal.show() and the user dismisses
the modal by clicking the backdrop (which calls hide() via the adapter),
your await resolves with undefined instead of hanging.
useModal() reads a scoped dispatch from the closest enclosing Provider
via React context. This is deterministic under all conditions — multiple
Providers, StrictMode double-invoke, and concurrent rendering — because the
hook routes through context, not a module-level stack.
The module-level show / hide / remove functions dispatch to whichever
Provider is on top of an internal stack. When exactly one Provider is
mounted, this is unambiguous. With multiple Providers mounted the routing
target is unspecified — you will see a dev-only warning:
[CommandModal] Multiple Providers are currently mounted (N). Top-levelshow/hide/remove routes to an arbitrary Provider and should be consideredundefined in multi-Provider setups. Use useModal() inside your componenttree for scoped, deterministic dispatching.
If you need imperative access from outside a component (e.g. inside a
Redux thunk or a library integration), prefer useCommandModalDispatch()
and pass the dispatch where you need it.
Shows a modal and returns a promise. Both the args and the resolved type are
inferred from the component created by create<Props, Result> — no explicit
type argument:
// Component form — args (Props) and result (Result) inferred from the component:function show<C>(modal: C, args?: Partial<Props>): Promise<Result>// String-id form — the id carries no type, so pass the result explicitly:function show<T = unknown>(modal: string, args?: Record<string, unknown>): Promise<T>
Parameters:
modal - Modal component created with CommandModal.create() (or a string id)
args - Args passed to the modal component (validated against its Props)
Returns: Promise that resolves with the value passed to modal.resolve()
(typed Result), or undefined if the modal is dismissed without resolving.
Hook to access modal controls within a modal component. Inside the body, call
it with no argument (the modal id comes from context); the resolve type is the
Result you declared on create<Props, Result>.
const modal = useModal(); // inside a create()'d modal
Returns: Modal handler object with the following properties:
id - Unique modal identifier
visible - Current visibility state
keepMounted - Whether modal stays mounted when hidden
show() - Show the modal
hide() - Hide the modal
remove() - Remove the modal
resolve(value) - Resolve the modal promise with a value
reject(reason) - Reject the modal promise
resolveHide() - Called when modal animation completes
modalProps - Props object for your UI library's modal
Hook that returns the raw reducer dispatch of the closest enclosing
Provider, or null when called outside any Provider subtree.
function useCommandModalDispatch(): Dispatch<CommandModalAction> | null
Use this as an escape hatch when you need scoped, deterministic
dispatch from non-component code (library integrations, middlewares,
imperative helpers you pass into event handlers). For normal modal
control, prefer useModal().
The React context that carries the Provider-scoped dispatch. Exposed for
advanced use cases such as writing custom hooks that must interoperate
with the command-modal reducer. Most callers should use
useCommandModalDispatch() or useModal() instead.
An adapter maps the modal handler to the prop shape one UI library expects.
It is the single seam by which command-modal stays UI-library-agnostic. Each
adapter emits its own library's real prop names.
shadcn/ui (Base UI Dialog) is the zero-config default — you don't configure
anything. The default createModalProps emits Base UI's real props
{ open, onOpenChange, onOpenChangeComplete }, wiring teardown to
onOpenChangeComplete (guarded on close) so <Dialog {...modal.modalProps}>
removes the modal once its close animation finishes:
import { createModalProps } from '@easy-shadcn/command-modal';// Used automatically; you only import it to build on top of it.
For a non-shadcn library, pair its adapter with createCommandModal once. The
factory returns a Provider pre-bound to the adapter and a useModal whose
modalProps is statically typed as that library's props — no casts. antd
v6 is first-class: an official antdModalProps adapter ships at the /antd
subpath (no antd dependency is added to the core).
Set it up once in an app-local barrel, and re-export the imperative verbs from
the same module so app code has a single import source:
// lib/modal.ts — the only command-modal entry point in your appimport { createCommandModal, show, hide, remove, create,} from '@easy-shadcn/command-modal';import { antdModalProps } from '@easy-shadcn/command-modal/antd';// Call the factory once at module scope (never inside a render).export const { Provider, useModal } = createCommandModal(antdModalProps);export { show, hide, remove, create };
// any feature fileimport { useModal, show } from '@/lib/modal';const EditUser = create(() => { const modal = useModal(); // modal.modalProps is typed as antd's { open, onCancel, afterClose } return <Modal {...modal.modalProps}>…</Modal>;});
Guard the footgun. The package root also exports a useModal typed for
shadcn. If you accidentally import it instead of your factory's, modalProps
silently reverts to the shadcn shape. Forbid the root import in your project
so it fails in CI instead of at runtime:
By default, modals are removed from the DOM after they are hidden. To keep
a modal mounted across hide/show cycles (e.g. to preserve scroll position
or form state), pass keepMounted on the JSX-declared modal instance:
const MyModal = CommandModal.create(() => { const modal = CommandModal.useModal(); return <Dialog {...modal.modalProps}>...</Dialog>;});function App() { return ( <CommandModal.Provider> {/* Declare keepMounted on the HOC; it configures the modal itself. */} <MyModal id="my-modal" keepMounted /> {/* ...rest of the app */} </CommandModal.Provider> );}
Do not assign modal.keepMounted = true inside the render body — the
handler returned by useModal() is read-only. Use the keepMounted prop
on the JSX-declared modal as shown above.
Declare a modal's resolve (result) type as the second type argument to
create. It flows to show(), which infers both the args and the result
with no explicit type argument and no cast:
The result type is declared at the definition site (not the show() call site)
so that args-checking and a typed result can coexist — TypeScript has no partial
type-argument inference, so pinning the result at the call site would disable
args inference. The result type defaults to unknown when omitted, so modals
whose result you don't consume need no extra annotation.
Spread modal.modalProps directly onto your dialog so the adapter's post-close
hook reaches the underlying component. For the shadcn/Base UI default that hook
is onOpenChangeComplete; if you forward props by hand, don't drop it, or the
modal is never removed after it closes:
This dev-only warning fires when two or more <Provider> are mounted in
the React tree simultaneously AND top-level show / hide / remove
is called. Module-level helpers route to an internal stack of Providers,
and with more than one mounted the routing target is not guaranteed.
Fixes:
If the nested Provider is unintentional (e.g. a page layout wraps one
and a demo component wraps another), remove the inner Provider and
share the outer one.
If you deliberately run multiple Providers (isolated sub-apps, design
system docs rendering demos), switch to useModal() inside each
sub-tree — hooks route via context and are deterministic.
For imperative dispatch outside components, use
useCommandModalDispatch() at the relevant subtree to grab a scoped
dispatch and pass it where you need it.
This project is inspired by and built upon the patterns established by @ebay/nice-modal-react. Special thanks to the original authors for their pioneering work in imperative modal management.