Recently, I’ve stumbled upon a problem while building a Form in the Modal box. I would like to share that experience and believe it might help.
I wanted to create a modal which can show some content or the form. The best way to create a modal in React is to use Portal. Because, the modal should always be a individual component outside the DOM hierarchy. The Portal let you do this. Kindly read through the react’s documentation to know more about the Portal and the benefits. Additionally, this post might help you to understand better.
So, we know what is Portal! Let’s build our Modal
component and render as a Portal
. I’m using create-react-app CLI tool to generate my react project. Before creating the portal, let’s make sure our ./public/index.html
has the outer DOM hierarchy.
Before:
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
After:
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<div id="modal-root"></div>
</body>
I’ve added another div
with the value of id attribute as modal-root
. That is where we render all our modals.
Let’s create our Modal
component with Portal
support. I’ve created this under components/modal/index.js
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import { StyledModal } from "./style";
// Creates a portal outside the DOM hierarchy
function Portal({ children }) {
const modalRoot = document.getElementById("modal-root"); // A div with id=modal-root in the index.html
const element = document.createElement("div"); // Create a div element which will be mounted within modal-root
// useEffect bible: https://overreacted.io/a-complete-guide-to-useeffect/
useEffect(() => {
modalRoot.appendChild(element);
// cleanup method to remove the appended child
return function cleanup() {
modalRoot.removeChild(element);
};
});
return createPortal(children, element);
}
// A modal component which will be used by other components / pages
function Modal({ children, toggle, open }) {
return (
<Portal>
{open && (
<StyledModal.ModalWrapper onClick={toggle}>
<StyledModal.ModalBody onClick={event => event.stopPropagation()}>
<StyledModal.CloseButton onClick={toggle}>
×
</StyledModal.CloseButton>
{children}
</StyledModal.ModalBody>
</StyledModal.ModalWrapper>
)}
</Portal>
);
}
export default Modal;
Here, the Portal
method creates the portal and uses the useEffect
hook to append the div
element to the modal-root
element and removes while unmounting
. Here is the problem I faced, but wait till we uncover the issue.
The StyledModal
is the styled component and the code is below (created under /components/modal/style.js
):
import styled from "styled-components";
const ModalWrapper = styled.div`
position: fixed;
z-index: 1;
padding-top: 100px;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
`;
const ModalBody = styled.div`
background-color: #fefefe;
margin: auto;
padding: 20px;
border: 1px solid #888;
width: 30%;
`;
const CloseButton = styled.span`
color: #aaaaaa;
float: right;
font-size: 28px;
font-weight: bold;
&:hover,
&:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
`;
export const StyledModal = {
ModalWrapper,
ModalBody,
CloseButton
};
If you notice our Modal
component, there are 3 props:
open
from true
to false
or vice-versa.To toggle the Modal's
state, let’s create a new custom hook and call it as useToggle
. I’m creating useToggle.js
in the src
directory:
import { useState, useCallback } from "react";
// Toggles between true or false
function useToggle(initialValue = false) {
const [toggle, setToggle] = useState(initialValue);
return [toggle, useCallback(() => setToggle(status => !status), [])];
}
export default useToggle;
In this user can toggle between true
or false
. This will be used in our App
component.
Let’s rewrite our App
component in the index.js
:
function App() {
const [open, setOpen] = useToggle(false);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<button type="button" onClick={() => setOpen()}>
Open Modal
</button>
{open && (
<Modal open={open} toggle={setOpen}>
<h1>Hello Modal</h1>
</Modal>
)}
</div>
);
}
The useToggle
hook gives the state of toggle
through a parameter called open
and the setOpen
let you to toggle the value of the open
. The rest of the code is self-explanatory.
When you run, you don’t see any problem. Great! We’ve built the Modal which shows the heading. Let’s extend it and add a form to our modal component with one input box.
I’ve modified my App
component with an input
element under the form
.
function App() {
const [open, setOpen] = useToggle(false);
const [username, setUsername] = useState("");
const onChangeUsername = e => setUsername(e.target.value);
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<button type="button" onClick={() => setOpen()}>
Open Modal
</button>
{open && (
<Modal open={open} toggle={setOpen}>
<h1>Hello Modal</h1>
<form onSubmit={e => e.preventDefault()}>
<input
type="text"
name="username"
value={username}
onChange={e => onChangeUsername(e)}
/>
</form>
</Modal>
)}
</div>
);
}
Now run the code and open up the modal. Try to enter more than one character in the displayed input box. Gosh, it is broken!!! For every character, the modal re-renders. Did you see that?
Okay, how to fix that now? I’ve spent ample of time to understand the issue. With some help of reddit users and useEffect
bible, I’ve found an issue in our Portal
component.
In our Portal
component, we’ve to put the div
element into the state and add modal-root
and div
as dependencies for the useEffect
. So that it doesn’t re-render. Let’s do this:
function Portal({ children }) {
const modalRoot = document.getElementById("modal-root"); // A div with id=modal-root in the index.html
const [element] = useState(document.createElement("div")); // Create a div element which will be mounted within modal-root
// useEffect bible: https://overreacted.io/a-complete-guide-to-useeffect/
useEffect(() => {
modalRoot.appendChild(element);
// cleanup method to remove the appended child
return function cleanup() {
modalRoot.removeChild(element);
};
}, [modalRoot, element]);
return createPortal(children, element);
}
Now run, and try the same which caused the problem. Voila! now it worked.
So always to remember, make sure useEffect
has the dependencies set properly to avoid re-rendering.
The sample code sandbox can be found here:
I hope my experience could help someone. If you like this article, kindly hit the Like button and Share.
Update: After a reddit user pointed out, we can simplify the Portal
without even the hooks.
function Portal({ children }) {
const modalRoot = document.getElementById('modal-root') // A div with id=modal-root in the index.html
return createPortal(<div>{children}</div>, element)
}
However, this post still remains valid which shows that user tend to do mistakes while creating useEffect
and how to avoid them by managing dependencies for the side effects.