Easy Shadcn
Packages

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() and hide() 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-modal

Requirements

  • React >=18 (the library uses React.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

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 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.

Scoped vs. Top-level API

The library exposes two routing modes for show / hide / remove:

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 content
  • config - 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 with CommandModal.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): void

Parameters:

  • 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 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

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 with CommandModal.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> | 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().

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 = 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.

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

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 }) => {
  // ...
});
  1. Check that Provider is wrapping your app
  2. Verify the modal component is created with CommandModal.create()
  3. 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

On this page