A centralized, type-safe modal system. Content components are decoupled from the Dialog shell, lazy-loaded on first open, and optionally synced to the URL so the back button works and links are shareable.
ModalManager.open("edit-user", { userId: 42 })
↓
contentRegistry ← your components + options, one place
↓
modalStore ← external store, works outside React
↓
ModalOutlet ← single outlet mounted near the app root
↓
ModalShell ← the only file that imports MUI Dialog
↓
<EditUserContent /> ← lazy-loaded, receives props + onClose
The only files you touch when adding a modal are Modals.ts (one entry in createRegistry) and your new content component file.
The content component receives its own props plus open and onClose from the shell via ModalInjectedProps. It does not import Dialog, DialogTitle, or any modal primitive — the shell handles all of that.
// features/users/EditUserContent.tsx
import React from 'react'
import { Stack, TextField, Typography } from '@mui/material'
import type { ModalInjectedProps } from 'ModalManager'
interface EditUserContentProps extends ModalInjectedProps {
userId: number
userName: string
}
export function EditUserContent({
userId,
userName,
onClose,
}: EditUserContentProps) {
const [name, setName] = React.useState(userName)
const handleSave = async () => {
await updateUser(userId, { name })
onClose() // close after success
}
return (
<Stack spacing={2}>
<Typography variant="body2" color="text.secondary">
User ID: {userId}
</Typography>
{/*
id="edit-user-form" matches the form= attribute on the Save button
in renderActions — the footer button can submit this form without
being nested inside it.
*/}
<TextField
id="edit-user-form"
autoFocus
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
/>
</Stack>
)
}Add a single entry to createRegistry(). The prop types are read directly from the component — no separate type declaration needed.
// modal-manager/Modals.ts
import React from 'react'
import { Button } from '@mui/material'
export const contentRegistry = createRegistry({
'edit-user': {
component: React.lazy(() =>
import('@/features/users/EditUserContent').then(m => ({ default: m.EditUserContent }))
),
options: {
title: ({ userName }) => `Edit ${String(userName)}`,
maxWidth: 'sm',
syncUrl: true,
renderActions: ({ onClose }) => (
<>
<Button onClick={onClose} color="inherit">Cancel</Button>
<Button variant="contained" form="edit-user-form" type="submit">Save</Button>
</>
),
},
},
})That's it. ModalManager.open() is immediately type-safe — the prop types come from the component itself:
ModalManager.open('edit-user', { userId: 42, userName: 'Ali' }) // ✅
ModalManager.open('edit-user', { src: 'photo.jpg' }) // ❌ type error
ModalManager.open('unknown', {}) // ❌ type errorFor simple confirmations the content is minimal — just the message body, with the confirm action triggered via a form so the footer button can fire it:
// features/shared/ConfirmDeleteContent.tsx
import { Typography } from '@mui/material'
import type { ModalInjectedProps } from 'ModalManager'
interface ConfirmDeleteContentProps extends ModalInjectedProps {
itemId: string
label: string
onConfirm: () => void
}
export function ConfirmDeleteContent({
label,
onConfirm,
onClose,
}: ConfirmDeleteContentProps) {
return (
<form
id="confirm-delete-form"
onSubmit={(e) => {
e.preventDefault()
onConfirm()
onClose()
}}
>
<Typography>
Are you sure you want to delete <strong>{label}</strong>? This cannot be
undone.
</Typography>
</form>
)
}// Modals.ts — add alongside the other entries
'confirm-delete': {
component: React.lazy(() =>
import('@/features/shared/ConfirmDeleteContent').then(m => ({ default: m.ConfirmDeleteContent }))
),
options: {
title: ({ label }) => `Delete ${String(label)}`,
maxWidth: 'xs',
syncUrl: false,
renderActions: ({ onClose }) => (
<>
<Button onClick={onClose} color="inherit">Cancel</Button>
<Button variant="contained" color="error" form="confirm-delete-form" type="submit">
Delete
</Button>
</>
),
},
},Add this once near the root of your app. It renders the modal stack and sets up URL sync as a side effect.
// App.tsx
import { ModalOutlet } from 'ModalManager'
export default function App() {
return (
<>
<RouterOutlet />
<ModalOutlet />
</>
)
}ModalManager is a plain singleton — it works inside components, in event handlers, in API response callbacks, anywhere.
import { ModalManager } from 'ModalManager'
// Inside a component
<Button onClick={() => ModalManager.open('edit-user', { userId: user.id, userName: user.name })}>
Edit
</Button>
// Outside React — API handler, utility function, etc.
const id = ModalManager.open('confirm-delete', {
itemId: '123',
label: 'Order #42',
onConfirm: () => deleteOrder('123'),
})
ModalManager.close(id) // close a specific modal by id
ModalManager.close() // close the topmost modal
ModalManager.closeAll() // close everything (e.g. on route change)| Option | Type | Required | Description |
|---|---|---|---|
title |
string | (props) => string |
✅ | Dialog header. Receives the user-supplied props so you can build dynamic titles. |
maxWidth |
'xs' | 'sm' | 'md' | 'lg' | 'xl' |
— | MUI Dialog width. Defaults to 'sm'. |
syncUrl |
boolean |
— | Mirror open/close to ?modal= query param. Enables back-button close and shareable links. |
renderActions |
({ open, onClose }) => ReactNode |
— | Replaces the default "Close" button. Use form= + type="submit" to wire footer buttons to a form inside the content. |
ModalManager.open('edit-user', { userId: 42, userName: 'Ali' })
→ URL: /users?modal=edit-user&modalProps=<base64>
→ New browser history entry pushed
Back button → popstate fires → URL no longer has ?modal= → modal closes.
Refresh / paste URL → useUrlModalSync reads URL on mount → modal reopens with same props.
Note: Function props (
onConfirm, callbacks) are stripped from the URL — they cannot be serialized. Design modals withsyncUrl: trueto handle a missing callback gracefully, e.g. by falling back to a navigation action.
ModalShell.tsx is the only file that imports MUI Dialog primitives. To switch libraries, replace only that file. All content components remain untouched.
// ModalShell.tsx — swap the internals here, nothing else in the system changes
// MUI
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material'
// Radix (example swap)
import * as Dialog from '@radix-ui/react-dialog'modal-manager/
services/
registry.ts ← derived ModalRegistry type
store.ts ← ModalStore class (external store, no React)
ModalManager.ts ← open() / close() / closeAll() singleton
url.ts ← UrlSync class (pushState / replaceState / read)
useModalStore.ts ← useSyncExternalStore wrapper
useUrlModalSync.ts ← page-load hydration + popstate listener
ModalShell.tsx ← the only MUI Dialog import in the system
ModalOutlet.tsx ← renders the stack, mount once near root
index.ts ← public re-exports
Modals.ts ← createRegistry()