Command Modal
Imperative modal management library for React with TypeScript support
Introduction
@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.
Features
- Imperative API - Control modals with simple
show()andhide()calls - Type-safe - Full TypeScript support with generic types
- Flexible - Works with any modal UI library through adapters
- Promise-based - Async/await support for modal workflows
- React Hooks - Built-in hooks for state management
- Zero dependencies - Core library has no external dependencies
Installation
npm install @easy-shadcn/command-modal
# or
pnpm add @easy-shadcn/command-modal
# or
yarn add @easy-shadcn/command-modalRequirements
- React
>=18(the library usesReact.useId()to generate SSR-stable modal ids).
Quick Start
1. Add Provider
Wrap your app with the CommandModal.Provider:
import CommandModal from '@easy-shadcn/command-modal';
function App() {
return (
<CommandModal.Provider>
{/* Your app content */}
</CommandModal.Provider>
);
}2. Create a Modal Component
Create your modal component using the useModal hook:
import CommandModal from '@easy-shadcn/command-modal';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
interface UserFormModalProps {
username?: string;
}
const UserFormModal = CommandModal.create<UserFormModalProps>(({ username }) => {
const modal = CommandModal.useModal();
return (
<Dialog {...modal.modalProps}>
<DialogContent>
<DialogHeader>
<DialogTitle>User Form</DialogTitle>
<DialogDescription>
{username ? `Edit user: ${username}` : 'Create new user'}
</DialogDescription>
</DialogHeader>
<div>
{/* Your form content */}
</div>
</DialogContent>
</Dialog>
);
});
export default UserFormModal;3. Show the Modal
Call the modal from anywhere in your app:
import CommandModal from '@easy-shadcn/command-modal';
import UserFormModal from './UserFormModal';
function MyComponent() {
const handleClick = () => {
CommandModal.show(UserFormModal, { username: 'John' });
};
return <button onClick={handleClick}>Open Modal</button>;
}Core Concepts
Modal Lifecycle
Modals have three main states:
- Created - Modal is registered but not visible
- Visible - Modal is shown on screen
- Hidden - Modal is hidden but still mounted (unless
keepMounted: false)
Promise-based Workflows
CommandModal supports promise-based workflows for async operations:
const ConfirmModal = CommandModal.create<{ message: string }>(({ message }) => {
const modal = CommandModal.useModal();
const handleConfirm = () => {
modal.resolve(true);
modal.hide();
};
const handleCancel = () => {
modal.resolve(false);
modal.hide();
};
return (
<Dialog {...modal.modalProps}>
<DialogContent>
<p>{message}</p>
<button onClick={handleConfirm}>Confirm</button>
<button onClick={handleCancel}>Cancel</button>
</DialogContent>
</Dialog>
);
});
// Usage
const result = await CommandModal.show(ConfirmModal, {
message: 'Are you sure?',
});
if (result) {
console.log('User confirmed');
} else {
console.log('User cancelled');
}Promise Settlement Semantics
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 pendingshow()promise withundefinedbefore changing visibility. Yourresolve()/reject()calls still win if they happened first (promises can only settle once).remove(modal)settles any pendinghide()promise withundefined.- When a
ModalDefunmounts orunregister()fires with pending promises, both are settled withundefined.
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.
Scoped vs. Top-level API
The library exposes two routing modes for show / hide / remove:
Hook-based (recommended)
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.
function MyButton() {
const modal = useModal(MyModal);
return <button onClick={() => modal.show({ foo: 'bar' })}>Open</button>;
}Top-level imports (legacy / imperative)
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-level
show/hide/remove routes to an arbitrary Provider and should be considered
undefined in multi-Provider setups. Use useModal() inside your component
tree 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.
API Reference
CommandModal.Provider
The root provider component that manages modal state.
interface CommandModalProviderProps {
children: React.ReactNode;
config?: CommandModalConfig;
}Props:
children- Your app contentconfig- Optional configuration object
CommandModal.create()
Creates a modal component with proper typing.
function create<TProps = Record<string, unknown>>(
component: React.ComponentType<TProps>
): React.ComponentType<TProps>Type Parameters:
TProps- Props type for your modal component
Returns: A wrapped component that can be used with CommandModal
CommandModal.show()
Shows a modal and returns a promise.
function show<TProps, TResult = unknown>(
modal: React.ComponentType<TProps>,
props?: TProps
): Promise<TResult>Parameters:
modal- Modal component created withCommandModal.create()props- Props to pass to the modal
Returns: Promise that resolves when modal calls modal.resolve()
CommandModal.hide()
Hides a specific modal.
function hide(modal: React.ComponentType): Promise<void>Parameters:
modal- Modal component to hide
Returns: Promise that resolves when modal is hidden
CommandModal.remove()
Removes a modal from the DOM.
function remove(modal: React.ComponentType): voidParameters:
modal- Modal component to remove
CommandModal.useModal()
Hook to access modal controls within a modal component.
function useModal<TResult = unknown>(): CommandModalHandler<TResult>Returns: Modal handler object with the following properties:
id- Unique modal identifiervisible- Current visibility statekeepMounted- Whether modal stays mounted when hiddenshow()- Show the modalhide()- Hide the modalremove()- Remove the modalresolve(value)- Resolve the modal promise with a valuereject(reason)- Reject the modal promiseresolveHide()- Called when modal animation completesmodalProps- Props object for your UI library's modal
CommandModal.useModalHolder()
Hook to control a modal from outside the modal component.
function useModalHolder<TProps>(
modal: React.ComponentType<TProps>
): [React.ComponentType<TProps>, CommandModalHandler]Parameters:
modal- Modal component created withCommandModal.create()
Returns: Tuple of [ModalComponent, handler]
Example:
function MyComponent() {
const [UserModal, userModal] = CommandModal.useModalHolder(UserFormModal);
return (
<>
<button onClick={() => userModal.show()}>Open</button>
<UserModal username="John" />
</>
);
}CommandModal.useCommandModalDispatch()
Hook that returns the raw reducer dispatch of the closest enclosing
Provider, or null when called outside any Provider subtree.
function useCommandModalDispatch(): Dispatch<CommandModalAction> | nullUse 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().
CommandModal.CommandModalDispatchContext
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.
Advanced Usage
Custom Modal Adapters
CommandModal uses adapters to work with different UI libraries. The default adapter works with shadcn/ui, but you can create custom adapters.
Default shadcn Adapter
import CommandModal, { shadcnModalAdapter } from '@easy-shadcn/command-modal';
// The shadcn adapter is used by default
<CommandModal.Provider>
<App />
</CommandModal.Provider>
// Or explicitly configure it
<CommandModal.Provider config={{ modalPropsAdapter: shadcnModalAdapter }}>
<App />
</CommandModal.Provider>Creating Custom Adapters
Create an adapter for your UI library:
import type { ModalPropsAdapter } from '@easy-shadcn/command-modal';
// Example: Ant Design adapter
interface AntdModalProps {
open: boolean;
onCancel: () => void;
afterClose: () => void;
}
const antdModalAdapter: ModalPropsAdapter<AntdModalProps> = (handler) => ({
open: handler.visible,
onCancel: () => handler.hide(),
afterClose: () => {
handler.resolveHide();
if (!handler.keepMounted) {
handler.remove();
}
},
});
// Use the custom adapter
<CommandModal.Provider config={{ modalPropsAdapter: antdModalAdapter }}>
<App />
</CommandModal.Provider>Example: Material-UI Adapter
import type { ModalPropsAdapter } from '@easy-shadcn/command-modal';
interface MuiDialogProps {
open: boolean;
onClose: () => void;
TransitionProps?: {
onExited?: () => void;
};
}
const muiDialogAdapter: ModalPropsAdapter<MuiDialogProps> = (handler) => ({
open: handler.visible,
onClose: () => handler.hide(),
TransitionProps: {
onExited: () => {
handler.resolveHide();
if (!handler.keepMounted) {
handler.remove();
}
},
},
});Keep Modal Mounted
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 = trueinside the render body — the handler returned byuseModal()is read-only. Use thekeepMountedprop on the JSX-declared modal as shown above.
Nested Modals
CommandModal supports nested modals out of the box:
const ConfirmModal = CommandModal.create(() => {
const modal = CommandModal.useModal();
return <Dialog {...modal.modalProps}>Confirm?</Dialog>;
});
const ParentModal = CommandModal.create(() => {
const modal = CommandModal.useModal();
const handleDelete = async () => {
const confirmed = await CommandModal.show(ConfirmModal);
if (confirmed) {
// Delete action
modal.hide();
}
};
return (
<Dialog {...modal.modalProps}>
<button onClick={handleDelete}>Delete</button>
</Dialog>
);
});Error Handling
Handle errors in modal workflows:
const FormModal = CommandModal.create(() => {
const modal = CommandModal.useModal();
const handleSubmit = async () => {
try {
const result = await submitForm();
modal.resolve(result);
modal.hide();
} catch (error) {
modal.reject(error);
modal.hide();
}
};
return <Dialog {...modal.modalProps}>...</Dialog>;
});
// Usage
try {
const result = await CommandModal.show(FormModal);
console.log('Success:', result);
} catch (error) {
console.error('Error:', error);
}TypeScript
Type-safe Props
interface UserFormProps {
userId: string;
mode: 'create' | 'edit';
}
const UserForm = CommandModal.create<UserFormProps>(({ userId, mode }) => {
// TypeScript knows userId and mode types
const modal = CommandModal.useModal();
return <Dialog {...modal.modalProps}>...</Dialog>;
});
// Type-safe usage
CommandModal.show(UserForm, {
userId: '123',
mode: 'edit',
});
// TypeScript error: missing required props
// CommandModal.show(UserForm, {}); // ❌Type-safe Results
interface FormResult {
name: string;
email: string;
}
const FormModal = CommandModal.create(() => {
const modal = CommandModal.useModal<FormResult>();
const handleSubmit = (data: FormResult) => {
modal.resolve(data); // Type-safe resolve
modal.hide();
};
return <Dialog {...modal.modalProps}>...</Dialog>;
});
// TypeScript knows the result type
const result: FormResult = await CommandModal.show(FormModal);Best Practices
1. Use TypeScript for Type Safety
Always define prop types and result types for better IDE support and type checking.
2. Clean Up Side Effects
Clean up subscriptions and side effects when modal is hidden:
const MyModal = CommandModal.create(() => {
const modal = CommandModal.useModal();
useEffect(() => {
if (!modal.visible) {
// Clean up when modal is hidden
return;
}
const subscription = subscribe();
return () => subscription.unsubscribe();
}, [modal.visible]);
return <Dialog {...modal.modalProps}>...</Dialog>;
});3. Separate Modal Logic
Keep complex logic in custom hooks:
function useUserForm(userId: string) {
const [data, setData] = useState();
const [loading, setLoading] = useState(false);
const submit = async () => {
setLoading(true);
// Submit logic
setLoading(false);
};
return { data, loading, submit };
}
const UserFormModal = CommandModal.create<{ userId: string }>(({ userId }) => {
const modal = CommandModal.useModal();
const form = useUserForm(userId);
return <Dialog {...modal.modalProps}>...</Dialog>;
});4. Avoid Blocking Operations
Don't perform blocking operations in modal render:
// ❌ Bad: Fetching in render
const MyModal = CommandModal.create(() => {
const data = fetchData(); // Don't do this
return <Dialog>...</Dialog>;
});
// ✅ Good: Fetch in effect
const MyModal = CommandModal.create(() => {
const [data, setData] = useState();
useEffect(() => {
fetchData().then(setData);
}, []);
return <Dialog>...</Dialog>;
});Migration Guide
From @ebay/nice-modal-react
CommandModal is largely compatible with nice-modal-react:
// Before (nice-modal-react)
import NiceModal, { useModal } from '@ebay/nice-modal-react';
const MyModal = NiceModal.create(() => {
const modal = useModal();
return <Dialog {...modal.modalProps}>...</Dialog>;
});
NiceModal.show(MyModal);
// After (command-modal)
import CommandModal from '@easy-shadcn/command-modal';
const MyModal = CommandModal.create(() => {
const modal = CommandModal.useModal();
return <Dialog {...modal.modalProps}>...</Dialog>;
});
CommandModal.show(MyModal);Key Differences:
- Must configure modal adapter through Provider config
- Default adapter is for shadcn/ui
- Enhanced TypeScript support
- Configurable modal props adapters
Troubleshooting
Modal Doesn't Close
Ensure you're calling afterClose in your modal component:
<Dialog
{...modal.modalProps}
// Make sure afterClose is called when animation completes
>
...
</Dialog>TypeScript Errors
Make sure you're using the correct generic types:
// Define props type
interface MyModalProps {
title: string;
}
// Pass type to create
const MyModal = CommandModal.create<MyModalProps>(({ title }) => {
// ...
});Modal Not Showing
- Check that Provider is wrapping your app
- Verify the modal component is created with
CommandModal.create() - Check browser console for errors
Multiple Providers are currently mounted Warning
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.
Examples
Confirmation Dialog
const ConfirmDialog = CommandModal.create<{
title: string;
message: string;
}>(({ title, message }) => {
const modal = CommandModal.useModal<boolean>();
return (
<AlertDialog {...modal.modalProps}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{message}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
modal.resolve(false);
modal.hide();
}}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => {
modal.resolve(true);
modal.hide();
}}>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
});
// Usage
const confirmed = await CommandModal.show(ConfirmDialog, {
title: 'Delete item?',
message: 'This action cannot be undone.',
});
if (confirmed) {
deleteItem();
}Form Modal with Validation
const FormModal = CommandModal.create<{ defaultValues?: FormData }>(
({ defaultValues }) => {
const modal = CommandModal.useModal<FormData>();
const [values, setValues] = useState(defaultValues || {});
const [errors, setErrors] = useState({});
const handleSubmit = () => {
const validationErrors = validate(values);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
modal.resolve(values);
modal.hide();
};
return (
<Dialog {...modal.modalProps}>
<DialogContent>
<DialogHeader>
<DialogTitle>Form</DialogTitle>
</DialogHeader>
<form>
{/* Form fields */}
</form>
<DialogFooter>
<Button onClick={() => modal.hide()}>Cancel</Button>
<Button onClick={handleSubmit}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
);Multi-step Wizard
const WizardModal = CommandModal.create(() => {
const modal = CommandModal.useModal();
const [step, setStep] = useState(1);
return (
<Dialog {...modal.modalProps}>
<DialogContent>
{step === 1 && <Step1 onNext={() => setStep(2)} />}
{step === 2 && <Step2 onNext={() => setStep(3)} onBack={() => setStep(1)} />}
{step === 3 && <Step3 onComplete={() => modal.hide()} onBack={() => setStep(2)} />}
</DialogContent>
</Dialog>
);
});Credits
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.
License
MIT