A lightweight, type-safe, and UI-framework-agnostic modal management system for React applications. Built with TypeScript and designed to work seamlessly with any modal/dialog component library.
Author: Rob Shelnutt (@webdevrob) Organization: Black Airplane License: MIT
This modal system provides a modern, promise-based API for managing modals in React applications. It eliminates the need for complex state management and prop drilling by using React Context and a factory pattern that returns promise-based modal controllers.
- 🎯 Promise-Based API: Open modals and await their results using async/await
- 🔒 Type-Safe: Full TypeScript support with generics for props and return values
- 🎨 UI-Framework Agnostic: Works with ANY modal/dialog component library
- 📦 Zero Dependencies: Only requires React and TypeScript
- 🪝 Hook-Based: Clean, modern React hooks API
- 🎭 Modal Stacking: Natural support for multiple modals with proper z-index handling
- ♻️ Reusable: Create modal definitions once, use them anywhere
This system is designed to work with any modal/dialog component library, including:
- ✅ Shadcn/ui Dialog (Radix UI based)
- ✅ Headless UI Dialog (Tailwind Labs)
- ✅ Radix UI Dialog (Direct usage)
- ✅ Ant Design Modal
- ✅ Material UI Dialog
- ✅ Chakra UI Modal
- ✅ React Bootstrap Modal
- ✅ Custom modal components
The system provides open and onOpenChange props that bind directly to your chosen UI library's modal component.
Copy these two files into your project:
src/
context/
ModalContext.tsx
hooks/
useModal.ts
Wrap your application root with the ModalProvider:
// App.tsx or main.tsx
import { ModalProvider } from './context/ModalContext'
function App() {
return (
<ModalProvider>
<YourApp />
</ModalProvider>
)
}Create a modal component using the Modal.create() factory and the useModalControl() hook:
// UserModal.tsx
import { Modal, useModalControl } from '@/hooks/useModal'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
interface UserModalProps {
user?: User
}
export const UserModal = Modal.create<UserModalProps, User>(
function UserModalComponent({ user }) {
const modal = useModalControl<User>()
const [formData, setFormData] = useState(user || { name: '', email: '' })
const handleSave = () => {
modal.resolve(formData) // Return the data
modal.hide() // Close the modal
}
const handleCancel = () => {
modal.reject(new Error('Cancelled'))
modal.hide()
}
return (
<Dialog {...modal}> {/* Spread modal props: open, onOpenChange */}
<DialogContent>
<DialogHeader>
<DialogTitle>{user ? 'Edit User' : 'Create User'}</DialogTitle>
</DialogHeader>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<button onClick={handleSave}>Save</button>
<button onClick={handleCancel}>Cancel</button>
</DialogContent>
</Dialog>
)
}
)Use the useModal() hook to get a controller for opening the modal:
// UserList.tsx
import { useModal } from '@/hooks/useModal'
import { UserModal } from './UserModal'
function UserList() {
const userModal = useModal(UserModal)
const handleCreateUser = async () => {
try {
const newUser = await userModal.show()
console.log('Created user:', newUser)
// Update your state, refetch data, etc.
} catch (error) {
console.log('User creation cancelled')
}
}
const handleEditUser = async (user: User) => {
try {
const updatedUser = await userModal.show({ user })
console.log('Updated user:', updatedUser)
} catch (error) {
console.log('User edit cancelled')
}
}
return (
<div>
<button onClick={handleCreateUser}>Create User</button>
{users.map(user => (
<button key={user.id} onClick={() => handleEditUser(user)}>
Edit {user.name}
</button>
))}
</div>
)
}import { Modal, useModalControl } from '@/hooks/useModal'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
export const ConfirmModal = Modal.create<{ message: string }, boolean>(
function ConfirmModalComponent({ message }) {
const modal = useModalControl<boolean>()
return (
<Dialog {...modal}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Action</DialogTitle>
</DialogHeader>
<p>{message}</p>
<button onClick={() => { modal.resolve(true); modal.hide() }}>
Confirm
</button>
<button onClick={() => { modal.resolve(false); modal.hide() }}>
Cancel
</button>
</DialogContent>
</Dialog>
)
}
)import { Modal, useModalControl } from '@/hooks/useModal'
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'
export const AlertModal = Modal.create<{ title: string; message: string }, void>(
function AlertModalComponent({ title, message }) {
const modal = useModalControl<void>()
return (
<Dialog open={modal.open} onClose={() => modal.hide()}>
<DialogPanel>
<DialogTitle>{title}</DialogTitle>
<p>{message}</p>
<button onClick={() => { modal.resolve(); modal.hide() }}>
OK
</button>
</DialogPanel>
</Dialog>
)
}
)import { Modal as AntModal } from 'antd'
import { Modal, useModalControl } from '@/hooks/useModal'
export const FormModal = Modal.create<{ initialData?: FormData }, FormData>(
function FormModalComponent({ initialData }) {
const modal = useModalControl<FormData>()
const [formData, setFormData] = useState(initialData || {})
return (
<AntModal
open={modal.open}
onCancel={() => modal.hide()}
onOk={() => { modal.resolve(formData); modal.hide() }}
title="Edit Form"
>
{/* Your form fields */}
</AntModal>
)
}
)import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material'
import { Modal, useModalControl } from '@/hooks/useModal'
export const DeleteConfirmModal = Modal.create<{ itemName: string }, boolean>(
function DeleteConfirmModalComponent({ itemName }) {
const modal = useModalControl<boolean>()
return (
<Dialog open={modal.open} onClose={() => modal.hide()}>
<DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete "{itemName}"?
</DialogContent>
<DialogActions>
<Button onClick={() => { modal.resolve(false); modal.hide() }}>
Cancel
</Button>
<Button onClick={() => { modal.resolve(true); modal.hide() }} color="error">
Delete
</Button>
</DialogActions>
</Dialog>
)
}
)Factory function that creates a modal definition.
Type Parameters:
TProps: The props interface for your modal componentTResult: The type of data the modal will resolve with
Parameters:
component: A React component that will be rendered as the modal
Returns: A ModalDefinition that can be passed to useModal()
Example:
const UserModal = Modal.create<{ user?: User }, User>(UserModalComponent)Hook used inside modal components to control the modal instance.
Type Parameters:
TResult: The type of data the modal will resolve with
Returns: ModalControl<TResult>
interface ModalControl<TResult> {
open: boolean // Current open state
onOpenChange: (open: boolean) => void // Handler for open state changes
hide: () => void // Close the modal
resolve: (value: TResult) => void // Resolve the promise with data
reject: (error: any) => void // Reject the promise with an error
}Usage:
const modal = useModalControl<User>()
// Bind to your UI library's modal component
<Dialog {...modal}> // Spreads: open, onOpenChange
// Or use individually
<Dialog open={modal.open} onOpenChange={modal.onOpenChange}>Hook used outside modal components to get a controller for opening modals.
Parameters:
modalDefinition: A modal definition created byModal.create()
Returns: ModalController<TProps, TResult>
interface ModalController<TProps, TResult> {
show: (props?: TProps) => Promise<TResult>
}Usage:
const userModal = useModal(UserModal)
// Open with props
const result = await userModal.show({ user: existingUser })
// Open without props
const result = await userModal.show()The system automatically handles multiple modals with natural z-index stacking:
const confirmModal = useModal(ConfirmModal)
const userModal = useModal(UserModal)
const handleDelete = async () => {
const user = await userModal.show({ user: selectedUser })
const confirmed = await confirmModal.show({
message: `Delete ${user.name}?`
})
if (confirmed) {
// Delete the user
}
}Use try/catch to handle modal cancellations and errors:
const userModal = useModal(UserModal)
const handleEdit = async () => {
try {
const result = await userModal.show({ user: selectedUser })
console.log('User updated:', result)
// Handle success - update state, refetch data, etc.
} catch (error) {
console.log('Modal was cancelled or errored:', error)
// Handle cancellation or errors
}
}You can also use .then() and .catch():
userModal.show({ user: selectedUser })
.then(result => {
console.log('User updated:', result)
})
.catch(error => {
console.log('Cancelled or errored:', error)
})Track steps within your modal component for wizard-style flows:
export const WizardModal = Modal.create<{}, WizardResult>(
function WizardModalComponent() {
const modal = useModalControl<WizardResult>()
const [step, setStep] = useState(0)
const [data, setData] = useState({})
const handleNext = () => setStep(step + 1)
const handleBack = () => setStep(step - 1)
const handleFinish = () => {
modal.resolve(data)
modal.hide()
}
return (
<Dialog {...modal}>
<DialogContent>
{step === 0 && <Step1 data={data} onChange={setData} onNext={handleNext} />}
{step === 1 && <Step2 data={data} onChange={setData} onNext={handleNext} onBack={handleBack} />}
{step === 2 && <Step3 data={data} onChange={setData} onFinish={handleFinish} onBack={handleBack} />}
</DialogContent>
</Dialog>
)
}
)Show loading states during async operations:
export const SaveModal = Modal.create<{ data: FormData }, SaveResult>(
function SaveModalComponent({ data }) {
const modal = useModalControl<SaveResult>()
const [isProcessing, setIsProcessing] = useState(false)
const [step, setStep] = useState(0) // 0: form, 1: processing, 2: success
const handleSave = async () => {
setIsProcessing(true)
setStep(1)
try {
const result = await saveToServer(data)
setStep(2)
// Show success state briefly before closing
setTimeout(() => {
modal.resolve(result)
modal.hide()
}, 1500)
} catch (error) {
setIsProcessing(false)
setStep(0)
// Show error message
}
}
return (
<Dialog {...modal}>
<DialogContent locked={isProcessing}>
{step === 0 && <FormView onSave={handleSave} />}
{step === 1 && <ProcessingView />}
{step === 2 && <SuccessView />}
</DialogContent>
</Dialog>
)
}
)The system includes a 300ms delay before removing modals from the DOM to allow for exit animations. This works automatically with most UI libraries.
If you need to adjust the animation duration, modify the timeout in ModalContext.tsx:
// In ModalInstanceProvider's hide function
setTimeout(() => {
closeModal(modalId)
}, 300) // Adjust this value to match your animation durationMost UI libraries handle animations automatically:
- Shadcn/ui: 200ms default animation
- Headless UI: Configurable with
transitionprops - Material UI: 225ms default animation
- Ant Design: 300ms default animation
The system is fully type-safe with TypeScript generics:
interface CreateUserProps {
defaultRole?: string
}
interface User {
id: number
name: string
email: string
}
// Type-safe modal definition
export const CreateUserModal = Modal.create<CreateUserProps, User>(
function CreateUserModalComponent({ defaultRole }) {
const modal = useModalControl<User>()
// TypeScript knows the shape of User
const handleSave = (user: User) => {
modal.resolve(user) // ✅ Type-safe
modal.hide()
}
// TypeScript enforces the User type
const invalidSave = () => {
modal.resolve({ invalid: 'data' }) // ❌ TypeScript error
}
// ...
}
)
// Type-safe usage
const createUserModal = useModal(CreateUserModal)
// Props are type-checked
const newUser = await createUserModal.show({ defaultRole: 'admin' }) // ✅
const error = await createUserModal.show({ invalidProp: 'value' }) // ❌ TypeScript error
// Return value is typed as User
console.log(newUser.name) // ✅ TypeScript knows this is a string
console.log(newUser.invalid) // ❌ TypeScript errorEach modal should have a single, clear purpose:
// ✅ Good - focused purpose
const DeleteUserModal = Modal.create<{ user: User }, boolean>(...)
const EditUserModal = Modal.create<{ user: User }, User>(...)
// ❌ Avoid - too many responsibilities
const UserManagementModal = Modal.create<{ action: 'create' | 'edit' | 'delete', user?: User }, any>(...)Make your modal's contract clear with descriptive types:
// ✅ Good - clear types
interface EditProfileProps {
userId: number
initialData: ProfileData
}
interface EditProfileResult {
updatedProfile: ProfileData
wasChanged: boolean
}
const EditProfileModal = Modal.create<EditProfileProps, EditProfileResult>(...)
// ❌ Avoid - unclear types
const EditProfileModal = Modal.create<any, any>(...)Always handle both resolve and reject cases:
const modal = useModalControl<User>()
const handleSave = () => {
modal.resolve(userData) // Success path
modal.hide()
}
const handleCancel = () => {
modal.reject(new Error('User cancelled')) // Cancellation path
modal.hide()
}
// Also handle close via X button or ESC key
// (automatically handled by onOpenChange)Reset modal state when it closes to ensure clean state for next open:
const modal = useModalControl<User>()
const [formData, setFormData] = useState(initialData)
// Reset when modal closes
useEffect(() => {
if (!modal.open) {
setFormData(initialData)
}
}, [modal.open])export const ConfirmDialog = Modal.create<{
title: string
message: string
confirmText?: string
cancelText?: string
}, boolean>(
function ConfirmDialogComponent({ title, message, confirmText = 'Confirm', cancelText = 'Cancel' }) {
const modal = useModalControl<boolean>()
return (
<Dialog {...modal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<p>{message}</p>
<DialogFooter>
<Button onClick={() => { modal.resolve(false); modal.hide() }}>
{cancelText}
</Button>
<Button onClick={() => { modal.resolve(true); modal.hide() }}>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
)
// Usage
const confirmed = await confirmDialog.show({
title: 'Delete User',
message: 'Are you sure you want to delete this user?',
confirmText: 'Delete',
cancelText: 'Cancel'
})export const FormModal = Modal.create<{ initialData?: FormData }, FormData>(
function FormModalComponent({ initialData }) {
const modal = useModalControl<FormData>()
const [formData, setFormData] = useState(initialData || {})
const [errors, setErrors] = useState({})
const validate = () => {
const newErrors = {}
if (!formData.name) newErrors.name = 'Name is required'
if (!formData.email) newErrors.email = 'Email is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSave = () => {
if (validate()) {
modal.resolve(formData)
modal.hide()
}
}
return (
<Dialog {...modal}>
<DialogContent>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={errors.name}
/>
<Button onClick={handleSave}>Save</Button>
</DialogContent>
</Dialog>
)
}
)Problem: Calling modal.show() but nothing happens.
Solution: Ensure ModalProvider is wrapping your app:
// main.tsx or App.tsx
<ModalProvider>
<YourApp />
</ModalProvider>Problem: TypeScript complains about modal props.
Solution: Ensure you're spreading the modal control correctly:
// ✅ Correct
<Dialog {...modal}>
// ✅ Also correct
<Dialog open={modal.open} onOpenChange={modal.onOpenChange}>
// ❌ Wrong
<Dialog open={modal.open}> // Missing onOpenChangeProblem: Modal stays open after calling hide().
Solution: Ensure your Dialog component respects the open and onOpenChange props:
// Your Dialog component must use these props
<Dialog open={modal.open} onOpenChange={modal.onOpenChange}>Problem: Modal content flickers or disappears before animation completes.
Solution: Adjust the timeout in ModalContext.tsx to match your animation duration:
// Increase from 300ms to match your animation
setTimeout(() => {
closeModal(modalId)
}, 500) // Match your animation durationContributions are welcome! Please feel free to submit issues or pull requests.
MIT License - feel free to use this in your projects!