Skip to content

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

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 Portal rendered to document.body bubbles to the React parent that rendered the Portal, even though the DOM elements are siblings or at completely different depths.

This creates two separate propagation paths that operate simultaneously. First, React’s synthetic event system follows the React component tree: events bubble from the Portal content up through whatever React component rendered the Portal, then to its parent, and so on. Second, native DOM events follow the actual DOM tree: events bubble from the target element up through document.body to document, completely unaware of the React component hierarchy. Code that mixes both systems — for example, a useRef with a native addEventListener alongside React onClick handlers — sees events traveling different routes depending on which system catches them.

The mismatch between these two paths is the source of most Portal event bugs. A stopPropagation() call on a React synthetic event stops React-tree bubbling but does not stop the native DOM event from reaching document-level listeners. A stopPropagation() call on the nativeEvent stops DOM bubbling but does not affect React’s synthetic propagation.

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-content -> e.stopPropagation() stops it reaching .modal-backdrop -> onClose 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: Handle Portal Hydration in Next.js and SSR

ReactDOM.createPortal requires a DOM element. During server-side rendering (Next.js, Remix), document doesn’t exist. Rendering a Portal on the server causes a crash or hydration mismatch.

// WRONG — crashes during SSR
function Modal({ children }) {
  return ReactDOM.createPortal(children, document.body);
  // ReferenceError: document is not defined (on the server)
}

// CORRECT — defer portal rendering to the client
function Modal({ children }) {
  const [mounted, setMounted] = useState(false);

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

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

Next.js App Router specifics: With React Server Components, a Portal component must be a Client Component (marked with 'use client'). The useEffect guard above also prevents hydration mismatches, since the server renders null and the client renders the Portal after mount.

Hydration mismatch with portal content: If the Portal renders visible content that should appear immediately (like a notification banner), rendering null on the server means a flash of missing content. In these cases, consider rendering the content inline during SSR and moving it to a Portal after hydration:

function Banner({ children }) {
  const [portalReady, setPortalReady] = useState(false);

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

  if (!portalReady) {
    // SSR and first client render — render inline
    return <div className="banner">{children}</div>;
  }

  // After hydration — render via portal
  return ReactDOM.createPortal(
    <div className="banner">{children}</div>,
    document.getElementById('banner-root')
  );
}

Fix 5: Portal Patterns in UI Libraries (Radix, Headless UI)

Radix UI, Headless UI, and similar libraries use Portals internally for dropdowns, dialogs, and popovers. Their event handling patterns differ from manual Portals.

Radix UI Dialog:

import * as Dialog from '@radix-ui/react-dialog';

// Radix handles Portal creation, focus trapping, and event isolation
function ConfirmDialog({ onConfirm }) {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Delete</Dialog.Trigger>
      <Dialog.Portal>
        {/* Renders to a Portal container Radix manages */}
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="content">
          <Dialog.Title>Confirm Delete</Dialog.Title>
          <button onClick={onConfirm}>Confirm</button>
          <Dialog.Close>Cancel</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Common issue: events from Radix Portal reaching parent React handlers. Radix uses stopPropagation on overlay clicks by default. If your parent onClick still fires, the event is bubbling through the React tree (expected Portal behavior). Radix provides onPointerDownOutside and onInteractOutside callbacks to handle dismiss behavior without relying on stopPropagation:

<Dialog.Content
  onPointerDownOutside={(e) => {
    // Prevent closing when clicking specific elements
    if (e.target.closest('[data-no-dismiss]')) {
      e.preventDefault();
    }
  }}
>

Headless UI and shadow DOM interaction: If your app uses Web Components with shadow DOM, Portals rendered outside the shadow root can’t capture events from inside it (and vice versa). Native DOM events don’t cross shadow boundaries during bubbling. Place the Portal container inside the same shadow root if you need event propagation between them.

Fix 6: 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 7: 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 8: 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 9: 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 — useful for diagnosing third-party library conflicts
const originalStopPropagation = Event.prototype.stopPropagation;
Event.prototype.stopPropagation = function() {
  console.trace('stopPropagation called');
  originalStopPropagation.call(this);
};
// Restore when done debugging

Still Not Working?

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 — check if the container already exists before creating a new one.

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.

React Native does not have PortalsReactDOM.createPortal is a web-only API. React Native does not support it. For modal-like behavior in React Native, use the built-in <Modal> component or a library like react-native-portal that emulates portal behavior using context-based rendering.

Portal events and React DevTools — React DevTools shows the component hierarchy including Portals. If an event seems to skip a component, check DevTools to verify the actual React tree structure. The Portal appears as a child of its React parent, not its DOM parent.

useId and Portals — React 18’s useId() generates consistent IDs between server and client. If a Portal is rendered only on the client (via useEffect), useId() inside the Portal still works but the ID won’t match any server-rendered HTML since the Portal wasn’t rendered on the server. This is fine for accessibility attributes that only need client-side consistency.

For related React issues, see Fix: React Suspense Not Triggering, Fix: React Hydration Error, Fix: React Context Not Updating, and Fix: React useEffect Runs Twice.

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