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 = object, TResult = unknown>(
  component: React.ComponentType<TProps>
): CreateModalComponent<TProps, TResult>

Type Parameters:

  • TProps - Props type for your modal component (validated at show() time)
  • TResult - The type show() resolves with (what you pass to modal.resolve()); defaults to unknown

Returns: A wrapped component carrying both TProps and TResult, for use with show()

CommandModal.show()

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.

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

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

Adapters

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.

Default shadcn adapter

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.

Typed adapters with createCommandModal (antd, …)

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 app
import {
  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 file
import { 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:

// eslint config — no-restricted-imports
{
  "paths": [{
    "name": "@easy-shadcn/command-modal",
    "importNames": ["useModal"],
    "message": "Import useModal from your createCommandModal factory (e.g. @/lib/modal)."
  }]
}

Writing your own adapter (BYO)

Any UI library works through a typed adapter — emit its real prop names and wire teardown to its post-close hook:

import type { ModalPropsAdapter } from '@easy-shadcn/command-modal';

// Example: Material UI <Dialog>
type MuiDialogProps = {
  open: boolean;
  onClose: () => void;
  TransitionProps?: { onExited?: () => void };
};

const muiModalProps: ModalPropsAdapter<MuiDialogProps> = (handler) => ({
  open: handler.visible,
  onClose: () => handler.hide(),
  TransitionProps: {
    onExited: () => {
      handler.resolveHide();
      if (!handler.keepMounted) {
        handler.remove();
      }
    },
  },
});

// Then: export const { Provider, useModal } = createCommandModal(muiModalProps);

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

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:

interface FormResult {
  name: string;
  email: string;
}

const FormModal = CommandModal.create<{ defaultName?: string }, FormResult>(
  ({ defaultName }) => {
    const modal = CommandModal.useModal();

    const handleSubmit = (data: FormResult) => {
      modal.resolve(data);
      modal.hide();
    };

    return <Dialog {...modal.modalProps}>...</Dialog>;
  }
);

// show() infers Promise<FormResult> AND validates the args.
const result = await CommandModal.show(FormModal, { defaultName: 'Ada' });
// result: FormResult

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.

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

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:

// ✅ spread everything — open, onOpenChange, onOpenChangeComplete
<Dialog {...modal.modalProps}>
  ...
</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 },
  boolean
>(({ title, message }) => {
  const modal = CommandModal.useModal();

  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 }, FormData>(
  ({ defaultValues }) => {
    const modal = CommandModal.useModal();
    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