How to handle multiple modals in a React application

NOTE: You can find the full example application here: https://stackblitz.com/edit/react-modals

There is not a single way to manage modals in a React application but some might be better than others. I'd like to present in this article a simpler way than handling modals using global store like Redux store. In this example, we'll use component state and event bubbling, touched on in React documentation on Portals

Modals are a little bit like separate screens usually managed by a router.

AppShell

It probably would make sense to render these two types of components close together in a central component, for example src/AppShell.jsx

import React, { useState } from 'react'
import { BrowserRouter, NavLink, Route, Switch } from 'react-router-dom'

import ScreenOne from './components/screen-one/ScreenOne'
import ScreenTwo from './components/screen-two/ScreenTwo'
import ScreenThree from './components/screen-three/ScreenThree'

import ModalOne from './components/common/modal-one/ModalOne'
import ModalTwo from './components/common/modal-two/ModalTwo'
import ModalThree from './components/common/modal-three/ModalThree'

import './app-shell.css'

const AppShell = () => {
  const [modalOpen, setModal] = useState(false)

  const openModal = event => {
    event.preventDefault()
    const { target: { dataset: { modal }}} = event
    if (modal) setModal(modal)
  }

  const closeModal = () => {
    setModal('')
  }

  return (
    <BrowserRouter>
      <div className="app--shell" onClick={openModal}>

        {/* Application header and navigation */}
        <header className="app--header">
          <h1>React Modal Windows</h1>
          <nav className="app--nav">
            <NavLink to="/screen-one">Screen One</NavLink>
            <NavLink to="/screen-two">Screen Two</NavLink>
            <NavLink to="/screen-three">Screen Three</NavLink>
          </nav>
        </header>

        {/* Application screens */}
        <Switch>
          <Route path="/screen-three">
            <ScreenThree />
          </Route>
          <Route path="/screen-two">
            <ScreenTwo />
          </Route>
          <Route path="/screen-one">
            <ScreenOne />
          </Route>
          <Route exact path="/">
            <ScreenOne />
          </Route>
        </Switch>

        {/* Modals */}        
        <ModalOne
          closeFn={closeModal}
          open={modalOpen === 'modal-one'} />

        <ModalTwo
          closeFn={closeModal}
          open={modalOpen === 'modal-two'} />

        <ModalThree
          closeFn={closeModal}
          open={modalOpen === 'modal-three'} />

        {/* Application footer */}
        <footer className="app--footer">
          <p className="copyright">&copy; 2021 Some Company</p>
        </footer>

      </div>
    </BrowserRouter>
  )
}

export default AppShell

Refactor into single-responsibility components

If your application contains a lot of screens and/or a lot of modals, we could extract routes and modals into separate components, for example ScreenSwitchboard.jsx and ModalManager.jsx so our AppShell.jsx component might look a little cleaner similar to

import React, { useState } from 'react'
import { BrowserRouter } from 'react-router-dom'

import AppHeader from './AppHeader'
import AppFooter from './AppFooter'

import ScreenSwitchboard from './ScreenSwitchboard'
import ModalManager from './ModalManager'

import './app-shell.css'


const AppShell = () => {
  const [modalOpen, setModal] = useState(false)

  const openModal = event => {
    event.preventDefault()
    const { target: { dataset: { modal }}} = event
    if (modal) setModal(modal)
  }

  const closeModal = () => {
    setModal('')
  }

  return (
    <BrowserRouter>
      <div className="app--shell" onClick={openModal}>
        <AppHeader />
        <ScreenSwitchboard />
        <ModalManager closeFn={closeModal} modal={modalOpen} />
        <AppFooter />
      </div>
    </BrowserRouter>
  )
}

export default AppShell

Use event bubbling to open specific modals

Notice that we capture bubbled click events on #app--shell element. Our event handler openModal that would trigger opening a specific modal looks for data-modal attribute which we could set on some elements (buttons, links, etc.) in our application.

Below is an example of a screen component with a button that triggers opening a modal when clicked.

import React from 'react'

const ScreenOne = ({}) => {

  return (
    <main className="app--screen screen--one">
      <h2>Screen One</h2>

      <div style={{ display: 'flex', columnGap: '1rem' }}>
        <button type="button" data-modal="modal-one">Open Modal One</button>
        <button type="button" data-modal="modal-two">Open Modal Two</button>
        <button type="button" data-modal="modal-three">Open Modal Three</button>
      </div>
    </main>
  )
}

export default ScreenOne

As you can probably see, we are not passing any functions or values as props down the hierarchy of our application. Instead, we rely on data-modal attribute and event bubbling to handle opening a specific modal.

ModalManager

Our <ModalManager /> component expects two props: state value as modal prop describing which modal should be open and closeFn prop which effectively directs the application to close any open modal.

NOTE: Modals might contain simple content or could handle more complex cases like processing forms. We don't want to rely on click event bubbling to handle their closing. It is simpler and more flexible to use a prop here.

Here is our <ModalManager /> component:

import React from 'react'

import ModalOne from './components/common/modal-one/ModalOne'
import ModalTwo from './components/common/modal-two/ModalTwo'
import ModalThree from './components/common/modal-three/ModalThree'


const ModalManager = ({
  closeFn = () => null,
  modal = ''
}) => (
  <>
    <ModalOne
      closeFn={closeFn}
      open={modal === 'modal-one'} />

    <ModalTwo
      closeFn={closeFn}
      open={modal === 'modal-two'} />

    <ModalThree
      closeFn={closeFn}
      open={modal === 'modal-three'} />
  </>
)

export default ModalManager

Now to the part which ties it all together--a React portal.

Use React portal to render a modal

Since the most common pattern is to display a single modal at a time, I think it makes sense to create a wrapper component which will render its children as a React portal.

Here's the code for src/components/common/modal/Modal.jsx component:

import React, { useEffect } from 'react'
import ReactDOM from 'react-dom'

const modalRootEl = document.getElementById('modal-root')

const Modal = ({
  children,
  open = false
}) => {
  if (!open) return null

  return ReactDOM.createPortal(children, modalRootEl)
}

export default Modal

Notice that we expect that #modal-root element will be available somewhere in our document, preferably as a sibling #app-root element where our application is mounted.

For example, <body /> in index.html could look like this:

<body>
  <div id="app-root"></div>
  <div id="modal-root"></div>
</body>

And finally, here's an example of a specific modal component:

import React from 'react';

import Modal from '../modal/Modal';

const ModalOne = ({ closeFn = () => null, open = false }) => {
  return (
    <Modal open={open}>
      <div className="modal--mask">
        <div className="modal-window">
          <header className="modal--header">
            <h1>Modal One</h1>
          </header>
          <div className="modal--body">
            <p>Modal One content will be rendered here.</p>
          </div>
          <footer className="modal--footer">
            <button type="button" onClick={closeFn}>
              Close
            </button>
          </footer>
        </div>
      </div>
    </Modal>
  );
};

export default ModalOne;

I have not covered everything in this article as I wanted to make it relatively short and simple with concrete examples. There are styling, accessibility and probably other factors to take into account.

You can find source code for this under the link posted at the top of this article.

Let me know in comments what you think about it and perhaps how you are managing modals in your application.

25