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 ^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 calluseModalmanually 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 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.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-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 caller