25
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.
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">© 2021 Some Company</p>
</footer>
</div>
</BrowserRouter>
)
}
export default AppShell
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
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.
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.
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