
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)
better-modal capabilities & compatibility
- Capabilities
- modal management · frontend build
- Use cases
- frontend · ui design
- Pricing
- Free
What better-modal says it does
Guide for using @buiducnhat/better-modal — the headless, Promise-based React modal state management library.
It centralises all modal state in a Zustand store and lets you open modals imperatively from **anywhere** in your app
Covers installation, all public APIs, React/Vercel best practices, TypeScript patterns, and common anti-patterns to avoid.
npx skills add https://github.com/buiducnhat/better-modal --skill better-modalAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 3 |
| Last updated | March 9, 2026 |
| Repository | buiducnhat/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
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 zustandPeer dependencies that must be installed separately:
react ^19zustand ^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.
| Return | Type | Description |
|---|---|---|
id | string | The ID used to register the modal |
show | (props?: TProps) => Promise<TResult> | Opens the modal, returns an awaitable Promise |
hide | () => void | Closes without resolving the Promise |
const MyModal = createModal<MyProps, MyResult>("my-modal", MyComponent);
const result = await MyModal.show({
/* props */
});
MyModal.hide();Rules:
- Call
createModalat module level (outside components) — it registers once. - The
idstring must be globally unique across your app. TPropstypes the props passed toshow().TResulttypes the resolved value.- If
TResultis omitted, it defaults tounknown.
---
useModal(id) — Hook used inside a modal component
Subscribe to a modal's state from within its own component.
const modal = useModal("my-modal");| Property | Type | Description |
|---|---|---|
visible | boolean | Whether the modal is currently open |
props | unknown | Props passed to show() |
show | (props?: unknown) => Promise<unknown> | Imperatively open this modal |
hide | () => void | Close without resolving |
resolve | (value: unknown) => void | Close + resolve the awaiting Promise |
reject | (reason: unknown) => void | Close + reject the awaiting Promise |
remove | () => void | Remove modal from store entirely (cleanup) |
Note:modalis already injected as a prop byModalContainer/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 siteNote: The library also rejects automatically in two edge cases:
>
- Reopen before resolve — ifshow()is called while the modal is already open, the previous Promise is rejected withError("Modal '...' was reopened before resolving").
- `remove()` before resolve — ifmodal.remove()is called on an unresolved modal, the Promise is rejected withError("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.tsx10. 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-pattern | Why it's wrong | Fix |
|---|---|---|
createModal inside a component | Re-registers on every render | Move to module scope |
<ModalContainer /> placed more than once | Renders every modal twice | Place exactly once at root |
Subscribing to full useModalStore() | Triggers re-renders for all modal state changes | Use useModal(id) or slice selectors |
modal.reject() without try/catch at call site | Unhandled Promise rejection | Use resolve(false) for cancellation, or wrap in try/catch |
Spreading modal.props onto JSX | Loses TypeScript type narrowing | Destructure explicitly |
| Storing modal IDs as magic strings across files | Typos cause silent failures | Export 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 callerRelated 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.