Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
buiducnhat avatar

Better Modal

  • 9 installs
  • 3 repo stars
  • Updated March 9, 2026
  • buiducnhat/better-modal

better-modal is a Claude skill guiding use of @buiducnhat/better-modal, a headless Promise-based React modal state manager built on Zustand.

About

This skill is a usage guide for @buiducnhat/better-modal, a headless Promise-based React modal state manager built on Zustand. A developer applies it when writing, reviewing, or refactoring modal code in a React 19+ app that uses this package. It covers installation, all public APIs (createModal, useModal, ModalContainer), TypeScript patterns, React performance best practices, and common anti-patterns.

  • Usage guide for the @buiducnhat/better-modal React modal library
  • Headless, Promise-based modal state managed in a Zustand store
  • Covers all public APIs, TypeScript patterns, and anti-patterns

Better Modal by the numbers

  • 9 all-time installs (skills.sh)
  • Ranked #1,714 of 2,245 Frontend Development skills by installs in the Skillselion catalog
  • Data as of Jul 28, 2026 (Skillselion catalog sync)
At a glance

better-modal capabilities & compatibility

Capabilities
modal management · frontend build
Use cases
frontend · ui design
Pricing
Free
From the docs

What better-modal says it does

Guide for using @buiducnhat/better-modal — the headless, Promise-based React modal state management library.
SKILL.md
It centralises all modal state in a Zustand store and lets you open modals imperatively from **anywhere** in your app
SKILL.md
Covers installation, all public APIs, React/Vercel best practices, TypeScript patterns, and common anti-patterns to avoid.
SKILL.md
npx skills add https://github.com/buiducnhat/better-modal --skill better-modal

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs9
repo stars3
Last updatedMarch 9, 2026
Repositorybuiducnhat/better-modal

What it does

A developer writes or refactors React modal code using the headless Promise-based better-modal library.

Who is it for?

Writing or refactoring modal code in a React 19+ app that uses @buiducnhat/better-modal.

Skip if: React apps that do not use the better-modal package or non-React modal work.

When should I use this skill?

Writing, reviewing, or refactoring modal code in a React app that uses @buiducnhat/better-modal.

What you get

Imperative, awaitable modal flows opened from anywhere with typed props and results.

Files

SKILL.mdMarkdownGitHub ↗

better-modal Usage Guide

@buiducnhat/better-modal is a headless, Promise-based modal state manager for React 19+. It centralises all modal state in a Zustand store and lets you open modals imperatively from anywhere in your app — including outside React components — then await the result.

---

Installation

# npm
npm install @buiducnhat/better-modal zustand

# bun
bun add @buiducnhat/better-modal zustand

Peer dependencies that must be installed separately:

  • react ^19
  • zustand ^5

---

Quick-Start (3 Steps)

Step 1 — Place <ModalContainer /> once at the app root

// app/layout.tsx (Next.js) or main.tsx (Vite)
import { ModalContainer } from "@buiducnhat/better-modal";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        {children}
        <ModalContainer /> {/* ← required, exactly once */}
      </body>
    </html>
  );
}

Step 2 — Define a modal with createModal

// modals/confirm-modal.tsx
import { createModal } from "@buiducnhat/better-modal";
import type { useModal } from "@buiducnhat/better-modal";

type Props = { title: string; message: string };

export const ConfirmModal = createModal<Props>(
  "confirm",
  ({ title, message, modal }) => (
    <dialog open={modal.visible}>
      <h2>{title}</h2>
      <p>{message}</p>
      <button onClick={() => modal.resolve(true)}>Confirm</button>
      <button onClick={() => modal.resolve(false)}>Cancel</button>
    </dialog>
  ),
);

Step 3 — Open the modal from anywhere

// Any component, server action boundary, or plain module
import { ConfirmModal } from "@/modals/confirm-modal";

async function handleDelete() {
  const confirmed = await ConfirmModal.show({
    title: "Delete item",
    message: "This action cannot be undone.",
  });
  if (confirmed) {
    await deleteItem();
  }
}

---

Public API

createModal(id, Component)ModalController<TProps, TResult>

Registers a modal component and returns its typed control object.

ReturnTypeDescription
idstringThe ID used to register the modal
show(props?: TProps) => Promise<TResult>Opens the modal, returns an awaitable Promise
hide() => voidCloses without resolving the Promise
const MyModal = createModal<MyProps, MyResult>("my-modal", MyComponent);

const result = await MyModal.show({
  /* props */
});
MyModal.hide();

Rules:

  • Call createModal at module level (outside components) — it registers once.
  • The id string must be globally unique across your app.
  • TProps types the props passed to show(). TResult types the resolved value.
  • If TResult is omitted, it defaults to unknown.

---

useModal(id) — Hook used inside a modal component

Subscribe to a modal's state from within its own component.

const modal = useModal("my-modal");
PropertyTypeDescription
visiblebooleanWhether the modal is currently open
propsunknownProps passed to show()
show(props?: unknown) => Promise<unknown>Imperatively open this modal
hide() => voidClose without resolving
resolve(value: unknown) => voidClose + resolve the awaiting Promise
reject(reason: unknown) => voidClose + reject the awaiting Promise
remove() => voidRemove modal from store entirely (cleanup)
Note: modal is already injected as a prop by ModalContainer / createModal
you only need to call useModal manually for more advanced use-cases.

---

<ModalContainer />

Renders all active modals. Place exactly once at the app root (layout level). Uses useShallow internally — only re-renders when the set of modal IDs changes.

<ModalContainer />

---

registerModal(id, Component) — Low-level API

Direct registration without the show/hide control object. Use only when createModal is not suitable (e.g., dynamic registration).

import { registerModal } from "@buiducnhat/better-modal";

registerModal("alert", AlertComponent);

---

Exported types — ModalController, ModalComponentProps

import type {
  ModalController,
  ModalComponentProps,
} from "@buiducnhat/better-modal";

`ModalController<TProps, TResult>` — the type of the object returned by createModal:

interface ModalController<TProps = Record<string, unknown>, TResult = unknown> {
  id: string;
  show: (props?: TProps) => Promise<TResult>;
  hide: () => void;
}

Use it to type a variable that holds a modal reference:

let modal: ModalController<{ name: string }, boolean>;
modal = createModal<{ name: string }, boolean>("name-modal", NameComponent);

`ModalComponentProps<TProps, TResult>` — helper type for explicitly typing a modal component's props:

type ModalComponentProps<TProps, TResult> = TProps & {
  modal: { visible: boolean; props: TProps; show: ...; hide: ...; resolve: (val: TResult) => void; ... };
};
import type { ModalComponentProps } from "@buiducnhat/better-modal";

type MyProps = { title: string };
type MyResult = boolean;

function MyModalComponent({
  title,
  modal,
}: ModalComponentProps<MyProps, MyResult>) {
  return (
    <dialog open={modal.visible}>
      <h2>{title}</h2>
      <button onClick={() => modal.resolve(true)}>OK</button>
    </dialog>
  );
}

---

TypeScript Best Practices

Type props and result separately

type ConfirmProps = { title: string };
type ConfirmResult = boolean;

export const ConfirmModal = createModal<ConfirmProps, ConfirmResult>(
  "confirm",
  ({ title, modal }) => {
    const confirm = () => modal.resolve(true satisfies ConfirmResult);
    const cancel = () => modal.resolve(false satisfies ConfirmResult);

    return (
      <dialog open={modal.visible}>
        <p>{title}</p>
        <button onClick={confirm}>Yes</button>
        <button onClick={cancel}>No</button>
      </dialog>
    );
  },
);

// Caller gets: Promise<ConfirmResult>
const ok = await ConfirmModal.show({ title: "Continue?" });

Keep modal files co-located with their feature

features/
  orders/
    delete-order-modal.tsx   ← modal definition
    orders-list.tsx          ← caller

---

React / Vercel Performance Best Practices

1. Slice subscriptions — never subscribe to the whole store

useModal already subscribes to state.modals[id] only. Do not bypass this by calling useModalStore directly and reading the full modals object.

// ✅ Correct — slice subscription (built into useModal)
const modal = useModal("confirm");

// ❌ Wrong — subscribes to entire store, causes all modals to re-render together
const store = useModalStore();

2. Stable references with useCallback / useMemo

All action functions returned by useModal are already memoised with useCallback. When you pass them as event handlers, no extra wrapping is needed.

// ✅ Correct — modal.resolve is already stable
<button onClick={() => modal.resolve(true)}>Confirm</button>;

// ❌ Unnecessary — wrapping a stable ref in another useCallback adds noise
const handleConfirm = useCallback(() => modal.resolve(true), [modal.resolve]);

3. Avoid mounting modals unconditionally in JSX trees

ModalContainer renders only modals that have been show()-called. Do not place modal components directly in your JSX tree — it defeats the library's purpose and forces unnecessary renders.

// ✅ Correct — driven by ModalContainer at the root
export const ConfirmModal = createModal("confirm", ConfirmComponent);

// ❌ Anti-pattern — manual conditional render in component tree
function Page() {
  const [open, setOpen] = useState(false);
  return <>{open && <ConfirmDialog />}</>;
}

4. Register modals at module level, not inside components

createModal is a registration side-effect. Call it once per modal at module scope.

// ✅ Module level
export const AlertModal = createModal("alert", AlertComponent);

// ❌ Inside a component — re-registers on every render
function App() {
  const AlertModal = createModal("alert", AlertComponent); // BUG
}

5. Use await for confirmation flows, not prop-drilling callbacks

// ✅ Promise-based — no callback prop-drilling required
async function handleDelete(id: string) {
  const ok = await ConfirmModal.show({ message: "Delete this item?" });
  if (!ok) return;
  await deleteItem(id);
}

// ❌ Old pattern — callback hell, tight coupling
function Parent() {
  const [showModal, setShowModal] = useState(false);
  const handleConfirm = () => {
    deleteItem(id);
    setShowModal(false);
  };
  return <ConfirmModal open={showModal} onConfirm={handleConfirm} />;
}

6. Handle Promise rejection — always catch or use resolve(false) for cancellation

// ✅ Resolve-based cancellation (preferred)
<button onClick={() => modal.resolve(false)}>Cancel</button>;

// ✅ If you use reject(), always handle it at the call site
try {
  const result = await DeleteModal.show();
} catch {
  // user cancelled
}

// ❌ Unhandled rejection — will throw an unhandled Promise error
<button onClick={() => modal.reject("cancelled")}>Cancel</button>;
// ...without try/catch at the call site
Note: The library also rejects automatically in two edge cases:

>

- Reopen before resolve — if show() is called while the modal is already open, the previous Promise is rejected with Error("Modal '...' was reopened before resolving").
- `remove()` before resolve — if modal.remove() is called on an unresolved modal, the Promise is rejected with Error("Modal '...' was removed before resolving").

>

Always handle rejections at the show() call site.

7. Clean up with modal.remove() for modals with heavy side-effects

hide() sets isOpen: false but keeps the state in the store. For modals with large props (e.g., image data) call remove() after close to free memory.

const MyModal = createModal("heavy", ({ data, modal }) => {
  const close = () => {
    modal.resolve(null);
    // modal.remove() is called automatically by resolve → hide,
    // but you can call remove() explicitly after animations complete
  };
  return <dialog open={modal.visible}>...</dialog>;
});

8. Vercel composition pattern — use children over render-props inside modals

When your modal component needs to accept arbitrary content, prefer children over render-prop patterns.

// ✅ children prop — clear, composable
type Props = { title: string; children: React.ReactNode };
const DialogModal = createModal<Props>(
  "dialog",
  ({ title, children, modal }) => (
    <dialog open={modal.visible}>
      <h2>{title}</h2>
      <div>{children}</div>
      <button onClick={() => modal.hide()}>Close</button>
    </dialog>
  ),
);

// Call site
DialogModal.show({ title: "Info", children: <p>Some content</p> });

9. Co-locate modal definitions with the feature that owns them

Avoid a single modals/ directory that becomes a dump of every modal in the app. Instead, keep modals adjacent to the feature components that use them.

features/
  payments/
    payment-error-modal.tsx
    payment-form.tsx
  settings/
    delete-account-modal.tsx
    account-settings.tsx

10. Avoid prop-spreading ...modal.props — be explicit

// ✅ Explicit props — type-safe and readable
const { title, message } = modal.props;
return (
  <dialog>
    <h2>{title}</h2>
    <p>{message}</p>
  </dialog>
);

// ❌ Spread loses type information
return <dialog {...modal.props} />;

---

Common Patterns

Confirmation dialog

// modals/confirm-modal.tsx
type ConfirmProps = { title: string; description?: string };

export const ConfirmModal = createModal<ConfirmProps>(
  "global-confirm",
  ({ title, description, modal }) => (
    <dialog open={modal.visible}>
      <h2>{title}</h2>
      {description && <p>{description}</p>}
      <footer>
        <button onClick={() => modal.resolve(true)}>Confirm</button>
        <button onClick={() => modal.resolve(false)}>Cancel</button>
      </footer>
    </dialog>
  ),
);

// Usage
const confirmed = await ConfirmModal.show({
  title: "Delete?",
  description: "Cannot undo.",
});

Form modal with typed result

// modals/edit-name-modal.tsx
type Props = { currentName: string };
type Result = { name: string } | null;

export const EditNameModal = createModal<Props>(
  "edit-name",
  ({ currentName, modal }) => {
    const [name, setName] = React.useState(currentName);

    return (
      <dialog open={modal.visible}>
        <input value={name} onChange={(e) => setName(e.target.value)} />
        <button onClick={() => modal.resolve({ name } satisfies Result)}>
          Save
        </button>
        <button onClick={() => modal.resolve(null)}>Cancel</button>
      </dialog>
    );
  },
);

// Usage
const result = await EditNameModal.show({ currentName: "Alice" });
if (result) {
  await updateName(result.name);
}

Alert / notification (fire-and-forget)

export const AlertModal = createModal<{ message: string }>(
  "alert",
  ({ message, modal }) => (
    <dialog open={modal.visible}>
      <p>{message}</p>
      <button onClick={modal.hide}>OK</button>
    </dialog>
  ),
);

// Fire-and-forget — no await needed
AlertModal.show({ message: "Saved successfully!" });

Using with shadcn/ui <Dialog>

import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { createModal } from "@buiducnhat/better-modal";

type Props = { title: string };

export const InfoModal = createModal<Props>("info", ({ title, modal }) => (
  <Dialog open={modal.visible} onOpenChange={(open) => !open && modal.hide()}>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>{title}</DialogTitle>
      </DialogHeader>
      <button onClick={modal.hide}>Close</button>
    </DialogContent>
  </Dialog>
));

Using with Radix UI <Dialog>

import * as Dialog from "@radix-ui/react-dialog";
import { createModal } from "@buiducnhat/better-modal";

export const ConfirmModal = createModal<{ message: string }>(
  "radix-confirm",
  ({ message, modal }) => (
    <Dialog.Root
      open={modal.visible}
      onOpenChange={(open) => !open && modal.resolve(false)}
    >
      <Dialog.Portal>
        <Dialog.Overlay />
        <Dialog.Content>
          <p>{message}</p>
          <button onClick={() => modal.resolve(true)}>Yes</button>
          <button onClick={() => modal.resolve(false)}>No</button>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  ),
);

---

Anti-Patterns Checklist

Anti-patternWhy it's wrongFix
createModal inside a componentRe-registers on every renderMove to module scope
<ModalContainer /> placed more than onceRenders every modal twicePlace exactly once at root
Subscribing to full useModalStore()Triggers re-renders for all modal state changesUse useModal(id) or slice selectors
modal.reject() without try/catch at call siteUnhandled Promise rejectionUse resolve(false) for cancellation, or wrap in try/catch
Spreading modal.props onto JSXLoses TypeScript type narrowingDestructure explicitly
Storing modal IDs as magic strings across filesTypos cause silent failuresExport the ID from the modal definition file

---

Data Flow Reference

Module scope
  createModal(id, Comp)
    └─► modalRegistry.set(id, Comp)   (plain Map, not Zustand)
    └─► returns { id, show, hide }

Runtime
  Modal.show(props)
    └─► useModalStore.open(id, props) → stores Promise resolvers
    └─► returns Promise<result>

  <ModalContainer /> (root)
    └─► subscribes to Object.keys(store.modals)  [useShallow]
    └─► for each id → <ModalRenderer id={id} />
          └─► Comp = modalRegistry.get(id)
          └─► modal = useModal(id)                [slice subscription]
          └─► <Comp {...modal.props} modal={modal} />

  modal.resolve(value)
    └─► calls stored Promise resolver
    └─► sets isOpen = false
    └─► Promise resolves in original caller

Related skills

FAQ

What is better-modal?

A headless, Promise-based modal state manager for React 19+ that centralizes modal state in a Zustand store.

What are its peer dependencies?

react ^19 and zustand ^5, installed separately.

How do you open a modal?

Call the controller's show() method, which returns an awaitable Promise resolving to the modal result.

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.