Skip to content

Repository files navigation

Modal Manager — Usage Guide

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.


How it works

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.


Adding a modal — two files, one step each

File 1 — Write the content component

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>
  )
}

File 2 — Register it in Modals.ts

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 error

Confirmation dialog example

For 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>
      </>
    ),
  },
},

One-time setup — mount <ModalOutlet />

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 />
    </>
  )
}

Opening and closing

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)

options reference

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.

URL sync behaviour (syncUrl: true)

ModalManager.open('edit-user', { userId: 42, userName: 'Ali' })
  → URL: /users?modal=edit-user&modalProps=<base64>
  → New browser history entry pushed

Back buttonpopstate fires → URL no longer has ?modal= → modal closes.

Refresh / paste URLuseUrlModalSync 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 with syncUrl: true to handle a missing callback gracefully, e.g. by falling back to a navigation action.


Changing the dialog library

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'

File structure

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()

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages