Fix: React Portal Event Bubbling Not Working — Events Not Reaching Parent
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 unintentionallyOr events stop propagating through the Portal when they shouldn’t:
// Event doesn't reach a context menu handler defined higher in the treeWhy 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.bodyPortal 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
documentorwindow) receive events from Portals — they don’t know about React’s virtual component tree
This creates two separate propagation paths:
- React synthetic event system — follows the React component tree
- 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 bugFix 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→onClosenot called - Click on
.modal-backdrop(outside content) →onClosecalled → 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 debuggingStill Not Working?
Server-side rendering with Portals — ReactDOM.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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.