This issue references to the fact that React intentionally remounts your components in development to find bugs.
I know I can turn off strict mode, but React doesn’t recommend it. I’m just looking for a recommended approach to fix this.
I’m trying to build an Open Modal Component that closes either when the close button is clicked, or the user clicks outside the modal contents.
So, to implement the outside click closing of the modal, I’m using a useEffect hook that I want its callback function to only run when the isModalPopedUp state changes, and won’t run on the initial render of the component. For this I use a custome hook:
import { useRef } from "react";
import { useEffect } from "react";
// This custom hook sets if a useEffect callback runs at initial render or not
export default function useDidMountEffect(argObject = {}) {
/*
argObject = {
runOnInitialRender: boolean,
callback: () => void,
dependencies: any[] // array of dependencies for useEffect
}
*/
const didMountRef = useRef(argObject.runOnInitialRender);
// useEffect to run
useEffect(() => {
if (didMountRef.current) {
// callback will run on first render if argObject.runOnInitialRender is true
argObject.callback();
} else {
// callback will now run on dependencies change
didMountRef.current = true;
}
}, argObject.dependencies); // only run if dependencies change
}
For the modal component, I split it into two components, the parent-modal that handles state logic:
import { useEffect, useState } from "react";
import Modal from "./modal";
import useDidMountEffect from "./useDidMountHook";
import "./modal.css";
export default function ModalParent() {
const [isModalPopedUp, setIsModalPopedUp] = useState(false);
function handleToggleModalPopup() {
setIsModalPopedUp((prevState) => !prevState);
}
function handleOnClose() {
setIsModalPopedUp(false);
}
function handleOutsideModalClick(event) {
!event.target.classList.contains("modal-content") && handleOnClose();
}
// add event listener for outside modal click using the useDidMountEffect hook
useDidMountEffect({
runOnInitialRender: false,
callback: () => {
// add event listener when modal is shown
if (isModalPopedUp) {
document.addEventListener("click", handleOutsideModalClick);
} else {
// remove event listener when modal is closed
document.removeEventListener("click", handleOutsideModalClick);
}
// return a cleanup function that removes the event listener when component unmounts
return () => {
document.removeEventListener("click", handleOutsideModalClick);
};
},
dependencies: [isModalPopedUp], // only re-run the effect when isModalPopedUp changes
});
return (
<div>
<button
onClick={() => {
handleToggleModalPopup();
}}>
Open Modal Popup
</button>
{isModalPopedUp && (
<Modal
header={<h1>Customised Header</h1>}
footer={<h1>Customised Footer</h1>}
onClose={handleOnClose}
body={<div>Customised Body</div>}
/>
)}
</div>
);
}
and the main modal component:
export default function Modal({ id, header, body, footer, onClose }) {
return (
<div id={id || "modal"} className="modal">
<div className="modal-content">
<div className="header">
<span onClick={onClose} className="close-modal-icon">
× {/* an X icon */}
</span>
<div>{header ? header : "Header"}</div>
</div>
<div className="body">
{body ? (
body
) : (
<div>
<p>This is our Modal body</p>
</div>
)}
</div>
<div className="footer">
{footer ? footer : <h2>footer</h2>}
</div>
</div>
</div>
);
}
So, the problem is that React remounts the parent component after initial render, making the callback for the useDidMountEffect to immediately add the click event listener to the document
element without having the isModalPopedUp state being changed to true by the "open modal" button click.
So, when clicking on the "open modal" button, isModalPopedUp is toggled to true but then is immediately changed to false due to the click event listener being added to the document prematurely. So, it eventually makes the modal unable to be opened by clicking the "open modal" button.
React.dev gives an easy fix by using the cleanup function returned by the useEffect callback to undo the changes from the remounting:
Usually, the answer is to implement the cleanup function. The cleanup function should stop or undo whatever the Effect was doing. The rule of thumb is that the user shouldn’t be able to distinguish between the Effect running once (as in production) and a setup → cleanup → setup sequence (as you’d see in development).
My cleanup function removes the click event listener from the document
element, but it still doesn’t fix the problem.
For the styling of the modal, I’m using:
/** @format */
.modal {
position: fixed;
z-index: 1;
padding-top: 2rem;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #b19b9b;
color: black;
overflow: auto;
padding-bottom: 2rem;
}
.modal-content {
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid red;
width: 80%;
animation-name: animateModal;
animation-duration: 0.5s;
animation-timing-function: ease-in-out;
}
.close-modal-icon {
cursor: pointer;
font-size: 40px;
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-weight: bold;
}
.header {
padding: 4px 10px;
background-color: #5cb85c;
color: white;
}
.body {
padding: 2px 16px;
height: 200px;
}
.footer {
padding: 4px 16px;
background-color: #5cb85c;
color: white;
}
@keyframes animateModal {
from {
top: -200px;
opacity: 0;
}
to {
top: 0;
opacity: 1;
}
}
2
Answers
This following will help you setup what you will need to handle the clicking outside of your Modal.
Along with this, you will need to set the reference and within a useEffect have something like this:
I think you misunderstand the concept of cleanup function. It’s your responsibility to write state, effects and interaction in a way that when everything is remounted and all effects do their thing, you are back at square one. In your case, you attach event listener and change state, but then in cleanup you only remove listener and don’t touch state. This means that you didn’t cleaned all up.
Another thing here is that it would be much easier to implement without effects at all. Modal dialog is "modal", meaning that user actually can’t interact with a page underneath it. Simple solution is to make an overlay above the visible content (it can be transparent if you want it "absent") and listen to the click on it (same one as on button). This way, to control a modal state you will need single boolean state variable and a single handler to change that variable.