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.

Everything a modal touches outside React The Modal component in the React tree creates four external resources: a portal container div appended to document body, focus-trap listeners registered on document, a scroll lock applied to body with a saved position, and an entry pushed to a global modal stack. React only removes the children it rendered into the container, so each external resource needs explicit cleanup in the effect's unmount path. <Modal> React-managed children portal container div in body — remove it yourself focus trap: focusin + keydown on document scroll lock: body styles + saved position global modal stack entry (props, callbacks)

Step-by-Step Fix

  1. Count body children and document listeners across opens. Open and close the dialog ten times, then run document.body.children.length in the Console and check Elements → select document → Event Listeners for focusin, keydown and scroll. Verification: you know whether containers or listeners accumulate.
  2. 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.
  3. 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.
  4. 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.
  5. Prefer a single long-lived portal root. Render all modals into one #modal-root that exists once in the page, rather than creating a container per instance. Verification: body child count is constant regardless of how many modals opened.
  6. Re-run the ten opens. Repeat steps 1 and 2. Verification: body children, document listeners and detached dialog nodes return to baseline after closing.
Twenty modal opens, before and after Before the fix, document body child count rises from 3 to 23 and document keydown listeners rise from 2 to 22 over twenty opens, one per open. After moving container removal and focus trap deactivation into the unmount cleanup, both stay at their baselines of 3 and 2. 24 0 body children (before) document keydown listeners (before) both after the fix (flat) modal opens 0 → 20

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.

Exercising every close path Open and close each modal type through every path: the close button, Escape, browser back navigation, and unmounting the parent while the exit animation runs. After ten or more cycles, assert that document.body child count and document listener counts are back at baseline and no detached dialog subtrees remain. Button / Escape the ordinary close paths Back navigation route changes while open Unmount mid-animation parent removed during exit Assert baselines body children, listeners

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.