React Portals and Modal Memory Leaks
Every time a dialog opens and closes, document.body gains another empty <div>, detached modal content piles up in heap snapshots, and after a long session keyboard focus behaves strangely because old focus traps are still listening. This guide from React Component Memory Leaks and Lifecycle Cleanup, in Framework-Specific Memory Optimization, explains the extra moving parts that portals and modals introduce, and how to make sure each of them is torn down when the dialog closes.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
document.body gains an empty <div> per open |
Portal container created per modal and never removed | Remove the container in the effect cleanup, or reuse one root | Body child count stable |
| Detached modal subtrees in snapshots | Something outside React still references modal DOM | Follow retainers: focus trap, scroll lock, animation lib | Modal content collectable after close |
Keydown/focusin listeners accumulate on document |
Focus-trap activation without deactivation | Deactivate the trap on close and unmount | Listener count constant |
| Page stays scroll-locked or restores to wrong position | Scroll-lock stack holds stale entries | Release the lock in cleanup; use a counted lock | No stale lock state |
| Global modal registry grows | Modals pushed to a store stack, never popped on unmount | Pop on unmount, not only on explicit close | Registry size equals open modals |
Root Cause: A Modal Is More Than Its Component
createPortal(children, container) renders children into a DOM node outside the parent’s DOM hierarchy while keeping them in the parent’s React tree. React manages the children it renders into the container, but not the container itself: if your code does document.createElement('div') and document.body.append(div) to create a portal target, React will never remove that div. A modal component that creates its own container on mount and forgets to remove it on unmount leaves one empty node in the body per opening — and if anything references the old container (a ref stored in a module, a library holding it), everything rendered into it stays reachable too.
Modals also typically bring three side systems. A focus trap listens on document for focusin and keydown to keep keyboard focus inside the dialog and remembers the previously focused element to restore later. A scroll lock adds styles to body, saves the scroll position, and may keep a stack of active locks. Animation or transition libraries keep exiting elements mounted, or clone them, until the exit animation ends. Each of these holds references to DOM nodes and closures. If the dialog unmounts without deactivating them — for example because a route change unmounts the modal without going through its onClose path — they keep the dialog’s nodes alive as detached DOM and their listeners on document keep running.
A fourth source is application-level: modal managers that keep a stack of open modals in global state, storing component props, callbacks or element refs, and pop entries only when the user closes the dialog. Unmounts that bypass the close handler leave entries behind. The same principle applies to all of these: cleanup belongs in the unmount path (the effect cleanup), because unmount happens in more situations than an explicit close.
Step-by-Step Fix
- Count body children and document listeners across opens. Open and close the dialog ten times, then run
document.body.children.lengthin the Console and check Elements → selectdocument→ Event Listeners forfocusin,keydownandscroll. Verification: you know whether containers or listeners accumulate. - Snapshot for detached modal content. Take a heap snapshot, filter by
Detached, and look for elements with your modal’s classes. Verification: detached dialog subtrees grow with the number of opens. - Read the retainers. Follow the path from a detached dialog root to the first non-React owner. Verification: you identify the container reference, focus trap, scroll lock, animation library or modal stack.
- Move cleanup into the unmount path. In one effect, create or acquire each resource and return a cleanup that removes the container, deactivates the focus trap, releases the scroll lock and pops the modal stack entry. Verification: unmounting the modal by any route (close button, Escape, navigation) runs the cleanup.
- Prefer a single long-lived portal root. Render all modals into one
#modal-rootthat exists once in the page, rather than creating a container per instance. Verification: body child count is constant regardless of how many modals opened. - Re-run the ten opens. Repeat steps 1 and 2. Verification: body children, document listeners and detached dialog nodes return to baseline after closing.
Command and Code Reference
Use case: a modal that owns its external resources and releases them on unmount.
import { createPortal } from 'react-dom';
import { useEffect, useRef } from 'react';
const modalRoot = document.getElementById('modal-root'); // exists once in index.html
export function Modal({ onClose, children }) {
const dialogRef = useRef(null);
useEffect(() => {
const previousFocus = document.activeElement;
const controller = new AbortController(); // one signal for all listeners
const { signal } = controller;
// Focus trap: keep Tab inside the dialog
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Tab') keepFocusInside(dialogRef.current, e);
}, { signal });
// Scroll lock with restoration
const scrollY = window.scrollY;
document.body.style.overflow = 'hidden';
dialogRef.current?.focus();
return () => { // runs on ANY unmount
controller.abort(); // removes all listeners
document.body.style.overflow = '';
window.scrollTo(0, scrollY);
if (previousFocus instanceof HTMLElement) previousFocus.focus();
};
}, [onClose]);
return createPortal(
<div role="dialog" aria-modal="true" tabIndex={-1} ref={dialogRef}>{children}</div>,
modalRoot, // shared root: nothing to remove
);
}
Use case: when a per-instance container is unavoidable, remove it in cleanup.
function usePortalContainer() {
const [el] = useState(() => document.createElement('div')); // created once per instance
useEffect(() => {
document.body.append(el);
return () => el.remove(); // React will not do this for you
}, [el]);
return el;
}
Verification and Regression Prevention
The fix holds when ten or more open/close cycles — including closes caused by route changes and Escape — leave document.body.children.length and document listener counts at their baselines, and the heap snapshot shows no detached dialog subtrees. Test the unusual paths explicitly: open a modal and navigate away with the browser back button, or unmount its parent while the exit animation is running.
Add an end-to-end test that opens and closes each modal type repeatedly and asserts on body child count; combine it with a Memlab scenario for retainer traces as in finding leaks with Memlab scenarios. If you use a third-party dialog, focus-trap or scroll-lock library, check that its hooks or components tie cleanup to unmount, and wrap direct API calls in effects that return deactivation — the same approach as for third-party widgets leaving detached DOM behind.
Edge Cases and Gotchas
Exit animations delay unmount
Transition components keep the modal mounted until the exit animation finishes. If the parent unmounts first, the transition library may keep a clone or a timer alive. Ensure the library handles parent unmount, or disable animations when navigating away.
Nested modals and counted locks
With stacked dialogs, the inner modal must not release the scroll lock the outer one still needs. Use a counted lock (increment on acquire, release only when the count returns to zero) rather than direct style toggling in each modal.
Server rendering and portal roots
document is not available during server rendering. Create or look up portal containers inside effects (or guard with a mounted flag) so server output and hydration do not create duplicate roots.
Focus restoration keeps an element reference
Remembering previousFocus holds a reference to an element while the modal is open. That is fine, but do not store it anywhere that outlives the modal; if the element was removed meanwhile, restoring focus to it is pointless and the reference keeps it alive.
Frequently Asked Questions
Does React remove the portal container when the component unmounts?
No. React removes the children it rendered into the container, but the container element itself belongs to your code. If you create it, you must remove it — or use a single container that exists for the lifetime of the page.
Why do modal leaks show up only after navigation?
Because route changes unmount the modal without calling its onClose handler. If cleanup lives in onClose rather than in the effect’s unmount cleanup, navigation leaves focus traps, scroll locks and registry entries behind.
How can I detect leaked focus-trap listeners?
Select document in the Elements panel and open Event Listeners, or use getEventListeners(document) in the Chrome Console, before and after several open/close cycles. Growing counts of keydown or focusin handlers indicate traps that were never deactivated.
Should every modal use its own portal container?
It is usually unnecessary. A single modal root in the document is simpler, avoids container leaks entirely and keeps stacking order predictable. Per-instance containers are only needed for special layering or isolation requirements.
Related
- React Component Memory Leaks and Lifecycle Cleanup — the parent topic
- Debugging Detached DOM Nodes in React Components — tracing detached modal content
- Event Listener Leaks and AbortController Cleanup — the listener cleanup pattern used above
- Framework-Specific Memory Optimization — the section overview