This guide helps you migrate from v1.x to v2.0 of @reactleaf/modal, which now uses direct component imports, a user-owned ModalManager instance, and context-based modal state.
The new context-based modal state removes the old prop injection approach that interfered with Fast Refresh.
- Direct component API instead of string modal names
- Promise-based results with
modal.open(...) - User-owned
ModalManagerfor custom control and inspection useModalInstance()forcloseSelfandvisible- Cleaner TypeScript types with no
BasicModalProps
register.tsis removeduseModal()andcreateModalHook()are removed- String modal names are removed
BasicModalPropsis removed
Why remove string modal names?
In v1, modals were looked up by name, so the actual modal component was often not imported at the SSR render boundary. That caused problems for styling systems that rely on import-based className analysis, such as Tailwind, because the modal component source might not be visible during analysis. v2 switches to direct component usage so the modal component is imported explicitly and those class names can be analyzed correctly.
npm install @reactleaf/modal@2Before (v1.x):
// modals/register.ts
export const register = {
Alert: () => import("./Alert"),
Confirm: () => import("./Confirm"),
};After (v2.0):
// Delete this file completelyBefore (v1.x):
import { ModalProvider } from "@reactleaf/modal";
import register from "./modals/register";
function App() {
return (
<ModalProvider register={register}>
<YourApp />
</ModalProvider>
);
}After (v2.0):
import { ModalManager, ModalProvider } from "@reactleaf/modal";
const modal = new ModalManager();
function App() {
return (
<ModalProvider
manager={modal}
defaultLayerOptions={{ closeOnOutsideClick: true }}
rootOptions={{ preventScroll: true }}
>
<YourApp />
</ModalProvider>
);
}The modal instance passed to ModalProvider must be the same instance you call later with modal.open(...).
Before (v1.x):
import { BasicModalProps } from "@reactleaf/modal";
interface AlertProps extends BasicModalProps {
message: string;
}
export default function Alert({ message, close }: AlertProps) {
return (
<div>
<p>{message}</p>
<button onClick={close}>OK</button>
</div>
);
}After (v2.0):
import { useModalInstance } from "@reactleaf/modal";
interface AlertProps {
message: string;
}
const Alert = ({ message }: AlertProps) => {
const { closeSelf, visible } = useModalInstance();
return (
<div className={visible ? "visible" : undefined}>
<p>{message}</p>
<button onClick={() => closeSelf("confirmed")}>OK</button>
</div>
);
};
Alert.layerOptions = {
closeOnOutsideClick: false,
};
export default Alert;Before (v1.x):
import { useModal } from "@reactleaf/modal";
function MyComponent() {
const { openModal } = useModal();
const handleShowAlert = () => {
openModal("Alert", { message: "Hello!" });
};
}After (v2.0):
import { ModalManager } from "@reactleaf/modal";
import Alert from "./modals/Alert";
const modal = new ModalManager();
async function handleShowAlert() {
const result = await modal.open(Alert, { message: "Hello!" });
console.log(result); // 'confirmed'
}const confirmed = await modal.open(Confirm, {
message: "Are you sure you want to delete this item?",
});
if (confirmed) {
deleteItem();
}import { MODAL_ABORTED } from "@reactleaf/modal";
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const result = await modal.open(Alert, { message: "This will auto-close..." }, { abortController: controller });
if (result === MODAL_ABORTED) {
console.log("Modal was aborted");
}await modal.open(EmptyPropsModal);
await modal.open(EmptyPropsModal, null, { abortController: controller });Options are merged in this order:
- Provider layer default options
- Component default options
- Call-time options
<ModalProvider
manager={modal}
defaultLayerOptions={{ closeOnOutsideClick: true }}
rootOptions={{ preventScroll: true }}
>
<App />
</ModalProvider>;
Alert.layerOptions = { closeOnOutsideClick: false };
await modal.open(Alert, { message: "Hello" }, { className: "alert-layer" });Modal components now read closeSelf and visible from useModalInstance():
const MyModal = ({ title, message }: { title: string; message: string }) => {
const { closeSelf, visible } = useModalInstance();
return (
<div className={`modal ${visible ? "visible" : ""}`}>
<h1>{title}</h1>
<p>{message}</p>
<button onClick={() => closeSelf("success")}>OK</button>
</div>
);
};You can inspect current stack state with getSnapshot():
const opened = modal.getSnapshot();For advanced cases, subscribe() is also available:
const unsubscribe = modal.subscribe((stack) => {
console.log(stack.length);
});Escapecloses the top modal- Browser back closes the top modal
- No extra setup required
// Old
import { useModal } from "@reactleaf/modal";
// New
import { ModalManager, ModalProvider, useModalInstance } from "@reactleaf/modal";// Old
interface AlertProps extends BasicModalProps {
message: string;
}
// New
interface AlertProps {
message: string;
}If modal.open(...) does nothing, check that:
- You created a
ModalManager - You passed that same instance to
ModalProvider - You are calling
open()on that same instance
If you get useModalInstance must be used within a ModalInstanceProvider:
- Make sure the component is opened via
modal.open(...) - Make sure
ModalProvideris mounted with the same manager instance - Don't call
useModalInstance()outside modal components
- Update TypeScript to a recent version
- Remove
BasicModalPropsfrom your component interfaces - Prefer
ModalComponent<Props, Result>when you wantmodal.open(Component, props)to infer the resolved result type - Use
nullas the second argument when passing options to a no-props modal
- Check that the modal component is imported correctly
- Verify the component is exported as default if your app expects that
- Ensure
ModalProvideris mounted - Ensure provider and call sites share the same manager instance