Skip to content

Fix: React Portal Event Bubbling Not Working — Events Not Reaching Parent

FixDevs ·

Quick Answer

How to fix React Portal event bubbling — understanding Portal event propagation, modal close on outside click, stopPropagation side effects, focus management, and accessibility.

The Problem

A React Portal’s events don’t bubble to the expected parent:

// Modal rendered via Portal to document.body
function Modal({ onClose }) {
  return ReactDOM.createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div className="modal-content" onClick={(e) => e.stopPropagation()}>
        Modal content here
      </div>
    </div>,
    document.body  // Rendered outside the React tree visually
  );
}

// Parent component — click outside modal should close it
function App() {
  const [open, setOpen] = useState(false);

  return (
    <div onClick={() => console.log('App clicked')}>
      <button onClick={() => setOpen(true)}>Open Modal</button>
      {open && <Modal onClose={() => setOpen(false)} />}
      {/* Clicking inside the modal logs 'App clicked' unexpectedly */}
    </div>
  );
}

Or a global click listener attached to document intercepts events meant for the Portal:

// Global listener added in a third-party library
document.addEventListener('click', closeAllDropdowns);
// This fires when clicking inside the Portal, closing dropdowns unintentionally

Or events stop propagating through the Portal when they shouldn’t:

// Event doesn't reach a context menu handler defined higher in the tree

Why This Happens

React Portals have a behavior that surprises many developers: events bubble through the React component tree, not the DOM tree.

When a Portal renders content to document.body, the content appears at the DOM root — but event bubbling follows the React parent hierarchy, not the DOM hierarchy. This means:

  • A click inside a document.body Portal bubbles to the React parent that rendered the Portal
  • stopPropagation() inside the Portal still stops bubbling up the React tree
  • Native DOM event listeners (on document or window) receive events from Portals — they don’t know about React’s virtual component tree

This creates two separate propagation paths:

  1. React synthetic event system — follows the React component tree
  2. Native DOM events — follow the actual DOM tree

Common issues arise when code mixes both systems (e.g., useRef + native addEventListener alongside React event handlers).

Fix 1: Understand Portal Event Bubbling

Before fixing, verify how events actually propagate in your Portal setup:

function DebugPortal() {
  return ReactDOM.createPortal(
    <div
      onClick={(e) => {
        console.log('Portal div clicked');
        console.log('Event target:', e.target);
        console.log('Current target:', e.currentTarget);
        // Bubbles to React parent — not DOM parent
      }}
    >
      Click me
    </div>,
    document.body
  );
}

function Parent() {
  return (
    <div onClick={() => console.log('React parent caught event!')}>
      {/* Portal renders to body, but events bubble to this div */}
      <DebugPortal />
    </div>
  );
}

// Clicking in DebugPortal logs:
// 1. "Portal div clicked"
// 2. "React parent caught event!"
// This is expected React behavior — not a bug

Fix 2: Implement “Click Outside to Close” Correctly

The most common Portal use case — closing a modal or dropdown when clicking outside:

import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

function Modal({ onClose, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    // Native DOM listener on document — catches all clicks
    function handleClickOutside(event) {
      if (modalRef.current && !modalRef.current.contains(event.target)) {
        onClose();
      }
    }

    // Use capture phase to run before other handlers
    document.addEventListener('mousedown', handleClickOutside, true);

    return () => {
      document.removeEventListener('mousedown', handleClickOutside, true);
    };
  }, [onClose]);

  return ReactDOM.createPortal(
    <div className="modal-backdrop">
      <div className="modal-content" ref={modalRef}>
        {children}
      </div>
    </div>,
    document.body
  );
}

Alternative — backdrop click closes the modal:

function Modal({ onClose, children }) {
  return ReactDOM.createPortal(
    <div
      className="modal-backdrop"
      onClick={onClose}    // Clicking the backdrop closes the modal
    >
      <div
        className="modal-content"
        onClick={(e) => e.stopPropagation()}  // Prevent backdrop click from triggering
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

Why stopPropagation in the backdrop approach works:

  • Click on .modal-contente.stopPropagation() stops it reaching .modal-backdroponClose not called
  • Click on .modal-backdrop (outside content) → onClose called → modal closes

Problem with stopPropagation: It stops the event from reaching other React listeners higher in the tree. If a parent component needs to know about clicks inside the modal (e.g., for analytics), those handlers won’t fire.

Fix 3: Fix Conflicts with Global Document Listeners

When a third-party library adds document-level click listeners, Portal events can trigger them unexpectedly:

// Third-party library attaches: document.addEventListener('click', closeMenus)
// Clicking inside your Portal triggers closeMenus unintentionally

// Fix 1 — stop propagation at the Portal root for native events only
function Modal({ onClose, children }) {
  const handleClick = (e) => {
    // Stop native event propagation (affects document listeners)
    // React synthetic events still bubble through React tree
    e.nativeEvent.stopImmediatePropagation();
  };

  return ReactDOM.createPortal(
    <div onClick={handleClick}>
      {children}
    </div>,
    document.body
  );
}
// Fix 2 — use a separate portal container, not document.body
// Third-party library might specifically target document.body
function Modal({ children }) {
  const portalContainer = useMemo(() => {
    const div = document.createElement('div');
    div.setAttribute('data-portal', 'modal');
    document.body.appendChild(div);
    return div;
  }, []);

  useEffect(() => {
    return () => {
      document.body.removeChild(portalContainer);
    };
  }, [portalContainer]);

  return ReactDOM.createPortal(children, portalContainer);
}

Recommended pattern — a persistent portal container:

// portal-root.tsx — a single container appended to body once
export function PortalRoot() {
  return <div id="portal-root" />;
}

// In layout or index.html:
// <div id="portal-root"></div>

// In Portal components — target the specific container
function Modal({ children }) {
  const portalRoot = document.getElementById('portal-root');
  if (!portalRoot) return null;
  return ReactDOM.createPortal(children, portalRoot);
}

Fix 4: Manage Focus Inside Portals

Portals for modals and dialogs must trap focus to be accessible:

import { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';

// Focus trap — keeps keyboard focus within the modal
function useFocusTrap(active) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!active || !containerRef.current) return;

    const focusableElements = containerRef.current.querySelectorAll(
      'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // Focus the first element when modal opens
    firstElement?.focus();

    function handleTab(e) {
      if (e.key !== 'Tab') return;
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    }

    document.addEventListener('keydown', handleTab);
    return () => document.removeEventListener('keydown', handleTab);
  }, [active]);

  return containerRef;
}

function Modal({ isOpen, onClose, children }) {
  const containerRef = useFocusTrap(isOpen);

  // Close on Escape key
  useEffect(() => {
    function handleKeyDown(e) {
      if (e.key === 'Escape') onClose();
    }
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [onClose]);

  if (!isOpen) return null;

  return ReactDOM.createPortal(
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={containerRef}
      style={{
        position: 'fixed', inset: 0, zIndex: 1000,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        background: 'rgba(0,0,0,0.5)',
      }}
    >
      <div style={{ background: 'white', borderRadius: 8, padding: 24, maxWidth: 500 }}>
        <h2 id="modal-title">Modal Title</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.getElementById('portal-root')!
  );
}

Fix 5: Use the usePortal Custom Hook

Encapsulate portal creation and cleanup in a reusable hook:

import { useState, useEffect, useCallback } from 'react';
import ReactDOM from 'react-dom';

function usePortal(containerId = 'portal-root') {
  const [container, setContainer] = useState<HTMLElement | null>(null);

  useEffect(() => {
    let el = document.getElementById(containerId);

    if (!el) {
      el = document.createElement('div');
      el.id = containerId;
      document.body.appendChild(el);
    }

    setContainer(el);

    return () => {
      // Only remove if this hook created it and it's now empty
      if (el && el.childNodes.length === 0) {
        el.remove();
      }
    };
  }, [containerId]);

  const Portal = useCallback(
    ({ children }: { children: React.ReactNode }) => {
      if (!container) return null;
      return ReactDOM.createPortal(children, container);
    },
    [container]
  );

  return Portal;
}

// Usage
function Tooltip({ text, targetRef }) {
  const Portal = usePortal('tooltip-root');

  return (
    <Portal>
      <div
        style={{
          position: 'fixed',
          // Position relative to targetRef.current.getBoundingClientRect()
        }}
      >
        {text}
      </div>
    </Portal>
  );
}

Fix 6: Fix Portal z-index and Stacking Context

Portals are often used for modals, tooltips, and dropdowns that need to appear above all other content. CSS stacking contexts can block this even for elements at document.body:

// Create a portal container at the very end of body
// to ensure it appears above other stacking contexts
function App() {
  return (
    <>
      <div id="app-root">
        {/* App content */}
      </div>
      <div id="portal-root" />  {/* Portals render here, after app content */}
    </>
  );
}
/* portal-root sits above everything */
#portal-root {
  position: relative;
  z-index: 9999;
}

/* Individual modals */
.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  background: rgba(0, 0, 0, 0.5);
}

Common z-index trap with Portals:

/* Parent has transform — creates a new stacking context */
.parent {
  transform: translateX(0);  /* Any transform creates a stacking context */
  /* Portal children can't escape this stacking context in CSS */
  /* This is WHY we use Portals — to escape this */
}

Rendering via Portal to document.body escapes transform-based stacking contexts entirely — that’s the primary use case for Portals.

Fix 7: Debugging Portal Event Issues

When portal events behave unexpectedly:

// Add event logging at different levels to trace propagation
function DebugWrapper({ children }) {
  return (
    <div
      onClickCapture={(e) => console.log('[Capture] Root', e.target)}
      onClick={(e) => console.log('[Bubble] Root', e.target)}
    >
      {children}
    </div>
  );
}

// Wrap your Portal to see the event flow:
<DebugWrapper>
  <Modal>
    <div onClick={(e) => console.log('[Bubble] Modal content')}>
      Click me
    </div>
  </Modal>
</DebugWrapper>

// Expected order on click inside modal:
// [Capture] Root (capture phase goes down)
// [Bubble] Modal content
// [Bubble] Root (bubble phase goes up through React tree)

Check if stopPropagation is breaking unrelated handlers:

// Log when propagation stops
const originalStopPropagation = Event.prototype.stopPropagation;
Event.prototype.stopPropagation = function() {
  console.trace('stopPropagation called');
  originalStopPropagation.call(this);
};
// Restore when done debugging

Still Not Working?

Server-side rendering with PortalsReactDOM.createPortal requires a DOM element. During SSR (Next.js, Remix), document.body doesn’t exist. Guard with typeof window !== 'undefined':

function Modal({ children }) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  if (!mounted) return null;
  return ReactDOM.createPortal(children, document.body);
}

React 18 createRoot and Portals — Portals work the same in React 18. However, with <React.StrictMode>, effects run twice in development. Ensure portal container creation in useEffect is idempotent.

Multiple stacked Portals — if a Portal renders inside another Portal’s content, both follow their respective React parent trees for event bubbling. Nested Portals work correctly as long as the React parent-child relationship is maintained.

For related React issues, see Fix: React Suspense Not Triggering and Fix: React Hydration Error.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles