React.js + Next.js · Module 3. Forms and Effects · Lesson 13 · Point 13
React Modal Windows, Portals, and Children
React + TypeScript · GoIT Dev Notes
This lesson builds on effect cleanup by creating a reusable modal with CSS Modules, createPortal, callback props, conditional rendering, keyboard events, scroll locking, and children.
You will learn how a parent controls a reusable child, how to close a modal safely, why cleanup matters for global listeners, and how one modal can display different content.
REACT · 01
1. What Is a Modal?
A modal is a dialog that appears above the main page content. It is commonly used to confirm deletion, show a contact form, display an image, or announce a successful action.
Start with an ordinary component. At this stage it appears in the normal page layout.
// src/components/Modal.tsx
export default function Modal() {
return (
<div>
<h2>Modal Title</h2>
<p>This is my first modal.</p>
</div>
);
}
// src/components/App.tsx
import Modal from "./Modal";
export default function App() {
return (
<div>
<h1>My Website</h1>
<Modal />
</div>
);
}
Browser Result
REACT · 02
2. Style the Backdrop and Dialog
The backdrop is the dark layer covering the screen. The dialog is the white content panel.
/* src/components/Modal.module.css */
.backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.6);
}
.modal {
position: relative;
width: min(90vw, 520px);
max-height: 90vh;
overflow: auto;
padding: 24px;
border-radius: 8px;
background-color: white;
color: #222;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border: 0;
border-radius: 4px;
background: white;
color: #333;
cursor: pointer;
font-size: 20px;
}
.closeButton:hover { background: #f2f2f2; }
.closeButton:focus-visible { outline: 2px solid #007bff; }
position: fixed attaches the backdrop to the viewport. inset: 0 is shorthand for setting all four edges to zero. Flexbox centers the panel.
Import a CSS Module and use its generated class names:
import css from "./Modal.module.css";
export default function Modal() {
return (
<div className={css.backdrop}>
<div className={css.modal}>Hello!</div>
</div>
);
}
Browser Result
REACT · 03
3. Render a Modal with createPortal
A portal lets React render JSX into a different DOM container while the component remains in the same React tree.
import { createPortal } from "react-dom";
import css from "./Modal.module.css";
export default function Modal() {
return createPortal(
<div className={css.backdrop}>
<div className={css.modal}>Hello!</div>
</div>,
document.body
);
}
createPortal(content, container) receives the JSX to render and the DOM element that should contain it. Here, the modal HTML becomes a child of document.body.
Portals help avoid ancestor layout problems such as clipping by overflow: hidden. They do not automatically provide focus management, keyboard behavior, or accessibility.
REACT · 04
4. Pass a Close Function Through Props
The parent owns the open state. The modal receives a callback that asks the parent to close it.
interface ModalProps {
onClose: () => void;
}
export default function Modal(props: ModalProps) {
return (
<button onClick={props.onClose} aria-label="Close modal">
×
</button>
);
}
onClose: () => void describes a function that receives no arguments and returns no useful value.
| Code | Meaning |
|---|---|
onClick={props.onClose} | Pass the callback for React to call later. |
onClick={props.onClose()} | Calls the function immediately during rendering and is wrong here. |
REACT · 05
5. Control Visibility with useState
import { useState } from "react";
import Modal from "./Modal";
export default function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
setIsModalOpen(true);
}
function closeModal() {
setIsModalOpen(false);
}
return (
<div>
<button onClick={openModal}>Open Modal</button>
{isModalOpen && <Modal onClose={closeModal} />}
</div>
);
}
When the state is false, React does not render the modal. When it is true, React mounts it. Setting the value back to false unmounts the modal.
The expression {isModalOpen && <Modal ... />} is conditional rendering.
Browser Result
REACT · 06
6. Close When the Backdrop Is Clicked
The modal should close when the dark backdrop is clicked, but not when the user clicks inside the white panel.
import type { MouseEvent } from "react";
function handleBackdropClick(event: MouseEvent<HTMLDivElement>) {
if (event.target === event.currentTarget) {
props.onClose();
}
}
event.targetis the element that was actually clicked.event.currentTargetis the element that owns this event handler.
If they are equal, the user clicked the backdrop itself. Without this check, a click inside the panel can bubble to the backdrop handler and close the modal.
REACT · 07
7. Close with Escape Using useEffect
A document-level keyboard listener is an external subscription, so it belongs in an Effect and needs cleanup.
useEffect(function () {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
props.onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return function () {
document.removeEventListener("keydown", handleKeyDown);
};
}, [props.onClose]);
The cleanup removes the exact listener added by the Effect. It runs before the Effect is refreshed and when the modal unmounts.
The dependency [props.onClose] keeps the listener synchronized if the callback reference changes.
REACT · 08
8. Lock Background Scrolling
The page behind an open modal should normally stay still. Save the existing inline value before changing it, then restore that value during cleanup.
useEffect(function () {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return function () {
document.body.style.overflow = previousOverflow;
};
}, []);
Changing document.body is a side effect. Restoring the previous value is safer than assuming it was originally an empty string.
REACT · 09
9. Make the Modal Reusable with children
Everything placed between a component's opening and closing tags is passed through the special children prop.
<Modal onClose={closeModal}>
<h2>Delete product</h2>
<p>Are you sure?</p>
</Modal>
Type reusable renderable content with ReactNode:
import type { ReactNode } from "react";
interface ModalProps {
onClose: () => void;
children: ReactNode;
}
export default function Modal(props: ModalProps) {
return (
<div>
<button onClick={props.onClose}>Close</button>
{props.children}
</div>
);
}
ReactNode accepts text, numbers, JSX elements, fragments, multiple elements, and other renderable React content. A type such as string would be too restrictive.
REACT · 10
10. Choose Different Messages with One Modal
One state can choose the content while another state controls visibility.
const [message, setMessage] = useState("delete");
const [isModalOpen, setIsModalOpen] = useState(false);
| State | Question it answers |
|---|---|
message | What content should appear? |
isModalOpen | Should the modal appear? |
<Modal onClose={closeModal}>
{message === "delete" && <p>Product deleted!</p>}
{message === "success" && <p>Order completed!</p>}
</Modal>
The modal structure stays the same. Only its children change.
REACT · 11
11. Accessibility Basics
<div
className={css.modal}
role="dialog"
aria-modal="true"
aria-label="Notification"
>
role="dialog"identifies the element as a dialog.aria-modal="true"declares that interaction is modal.aria-labelprovides the dialog with an accessible name.- An icon-only close button needs a name such as
aria-label="Close modal".
REACT · 12
12. Complete Learning Example
This example combines the portal, callback prop, children, backdrop handling, Escape listener, scroll lock, conditional rendering, and cleanup.
Modal.module.css
.backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.6);
}
.modal {
position: relative;
width: min(90vw, 520px);
max-height: 90vh;
overflow: auto;
padding: 24px;
border-radius: 8px;
background-color: white;
color: #222;
}
.closeButton {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border: 0;
border-radius: 4px;
background: white;
color: #333;
cursor: pointer;
font-size: 20px;
}
Modal.tsx
import { useEffect } from "react";
import type { MouseEvent, ReactNode } from "react";
import { createPortal } from "react-dom";
import css from "./Modal.module.css";
interface ModalProps {
onClose: () => void;
children: ReactNode;
}
export default function Modal(props: ModalProps) {
function handleBackdropClick(event: MouseEvent<HTMLDivElement>) {
if (event.target === event.currentTarget) {
props.onClose();
}
}
useEffect(function () {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
props.onClose();
}
}
const previousOverflow = document.body.style.overflow;
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return function () {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [props.onClose]);
return createPortal(
<div className={css.backdrop} onClick={handleBackdropClick}>
<div
className={css.modal}
role="dialog"
aria-modal="true"
aria-label="Notification"
>
<button
className={css.closeButton}
onClick={props.onClose}
aria-label="Close modal"
>
×
</button>
{props.children}
</div>
</div>,
document.body
);
}
App.tsx
import { useState } from "react";
import Modal from "./Modal";
type Message = "delete" | "success";
export default function App() {
const [message, setMessage] = useState<Message>("delete");
const [isModalOpen, setIsModalOpen] = useState(false);
function showDelete() {
setMessage("delete");
setIsModalOpen(true);
}
function showSuccess() {
setMessage("success");
setIsModalOpen(true);
}
function closeModal() {
setIsModalOpen(false);
}
return (
<div>
<h1>My Website</h1>
<button onClick={showDelete}>Show delete</button>
<button onClick={showSuccess}>Show success</button>
{isModalOpen && (
<Modal onClose={closeModal}>
{message === "delete" && (
<>
<h2>Delete product</h2>
<p>Product deleted!</p>
</>
)}
{message === "success" && (
<>
<h2>Order completed</h2>
<p>Thank you for your purchase!</p>
</>
)}
</Modal>
)}
</div>
);
}
Browser Result
What Happens When the User Clicks?
- The user clicks Show success.
showSuccess()selects the success message and opens the modal.- The success JSX becomes
props.children. createPortalplaces the modal HTML underdocument.body.- The Effect adds the Escape listener and locks scrolling.
- The user closes the modal.
- React unmounts it, removes the listener, and restores scrolling.
REACT · 13
13. Common Mistakes and Fixes
| Mistake | Correct approach |
|---|---|
Using onClick={closeModal()} | Pass the callback with onClick={closeModal}. |
| Closing for every bubbled backdrop click | Compare event.target and event.currentTarget. |
| Adding a listener without removing it | Return removeEventListener from the Effect. |
| Leaving body scrolling locked | Restore the previous overflow value during cleanup. |
| Hardcoding every message inside Modal | Pass customizable content through children. |
| Using one state for two different questions | Keep content selection separate from visibility. |
| Assuming a portal makes the modal accessible | Add labeling, focus behavior, and background interaction handling. |
REACT · 14
14. Practical Exercise
- Create
src/components/Modal.module.cssand add the backdrop, modal, and close-button styles. - Create
src/components/Modal.tsxwith the portal, props, children, backdrop handler, and Effect cleanup. - Update
src/components/App.tsxwith the two message buttons. - Run
npm run devand open the local URL. - Verify that the close button, dark backdrop, and Escape key all close the modal.
- Click inside the white panel and verify that it stays open.
- Add long page content and confirm that background scrolling is locked only while the modal is open.
Project Structure
src/
components/
App.tsx
Modal.tsx
Modal.module.css
main.tsx
Tiny Challenge: Add a Third Message
type Message = "delete" | "success" | "contact";
function showContact() {
setMessage("contact");
setIsModalOpen(true);
}
<button onClick={showContact}>Contact</button>
{message === "contact" && (
<>
<h2>Contact us</h2>
<p>Send us a message!</p>
</>
)}
Self-check: Why are message and isModalOpen separate? Why does a click inside the dialog not close it? What work does the Effect cleanup perform?
REACT · 15
15. Quick Review
- A modal overlays the normal page content.
- CSS Modules style the backdrop and dialog panel.
createPortalrenders JSX into another DOM container.- The parent owns the open state; the modal receives
onClose. event.target === event.currentTargetdetects a direct backdrop click.- An Effect registers the Escape listener and cleans it up.
- Cleanup also restores the page's previous scroll behavior.
children: ReactNodemakes one modal reusable for different content.- Accessibility requires more than a portal and a few ARIA attributes.
Main idea: the reusable modal owns the shared dialog behavior, while its parent owns visibility and chooses the content.