Skip to content

rshelnutt/react-global-context-modal

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 

Repository files navigation

React Global Context Modal System

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


Overview

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.

Key Benefits

  • 🎯 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

UI Framework Compatibility

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.


Installation

1. Copy the Source Files

Copy these two files into your project:

src/
  context/
    ModalContext.tsx
  hooks/
    useModal.ts

2. Add the ModalProvider

Wrap your application root with the ModalProvider:

// App.tsx or main.tsx
import { ModalProvider } from './context/ModalContext'

function App() {
  return (
    <ModalProvider>
      <YourApp />
    </ModalProvider>
  )
}

Basic Usage

Step 1: Create a Modal Component

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

Step 2: Use the Modal

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

Framework Examples

Example 1: Shadcn/ui Dialog (Radix UI)

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

Example 2: Headless UI 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>
    )
  }
)

Example 3: Ant Design Modal

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

Example 4: Material UI Dialog

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

API Reference

Modal.create<TProps, TResult>(component)

Factory function that creates a modal definition.

Type Parameters:

  • TProps: The props interface for your modal component
  • TResult: 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)

useModalControl<TResult>()

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

useModal(modalDefinition)

Hook used outside modal components to get a controller for opening modals.

Parameters:

  • modalDefinition: A modal definition created by Modal.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()

Advanced Patterns

Modal Stacking

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

Error Handling

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

Multi-Step Modals

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

Processing States

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

Animations

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 duration

Most UI libraries handle animations automatically:

  • Shadcn/ui: 200ms default animation
  • Headless UI: Configurable with transition props
  • Material UI: 225ms default animation
  • Ant Design: 300ms default animation

TypeScript Support

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 error

Best Practices

1. Keep Modal Components Focused

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

2. Use Descriptive Type Parameters

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

3. Handle All Exit Paths

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)

4. Reset State on Close

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

Common Patterns

Confirmation Dialog

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

Form Modal with Validation

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

Troubleshooting

Modal doesn't open

Problem: Calling modal.show() but nothing happens.

Solution: Ensure ModalProvider is wrapping your app:

// main.tsx or App.tsx
<ModalProvider>
  <YourApp />
</ModalProvider>

TypeScript errors with modal props

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 onOpenChange

Modal doesn't close

Problem: 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}>

Animation timing issues

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 duration

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests.


License

MIT License - feel free to use this in your projects!

About

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.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors