Technology · React

React Portals

Use portals to render components outside the DOM hierarchy for modals and overlays.

TL;DR
  1. 01Use ReactDOM.createPortal to render outside the DOM tree.
  2. 02Portals are useful for modals, tooltips, and dropdowns.
  3. 03Event bubbling still works through portals to parent components.

Creating Portals

  • Use ReactDOM.createPortal to render a component at a different location.
    import { createPortal } from "react-dom";
    
    function Modal({ children }) {
      const root = document.getElementById("modal-root");
      return createPortal(
        <div className="modal">{children}</div>,
        root
      );
    }
  • Create a target element in your HTML for the portal.
    <div id="root"></div>
    <div id="modal-root"></div>
  • Portals render outside the component hierarchy but stay in React.
  • Perfect for modals, dropdowns, tooltips, and overlays.
  • Check that the target element exists before rendering.
    function Portal({ children, containerId = "portal-root" }) {
      const container = document.getElementById(containerId);
      if (!container) return null;
      return createPortal(children, container);
    }
  • Build a reusable modal using portals for proper layering.
    function Modal({ isOpen, onClose, children, title }) {
      if (!isOpen) return null;
      
      return createPortal(
        <div className="modal-backdrop" onClick={onClose}>
          <div className="modal-content" onClick={e => e.stopPropagation()}>
            <h2>{title}</h2>
            {children}
            <button onClick={onClose}>Close</button>
          </div>
        </div>,
        document.getElementById("portal-root")
      );
    }
    
    function App() {
      const [open, setOpen] = useState(false);
      return (
        <>
          <button onClick={() => setOpen(true)}>Open Modal</button>
          <Modal isOpen={open} onClose={() => setOpen(false)} title="Hello">
            Modal content here
          </Modal>
        </>
      );
    }
  • Use stopPropagation to prevent backdrop clicks from closing the modal.
  • Trap keyboard focus inside the modal for accessibility compliance.
    useEffect(() => {
      if (!isOpen) return;
      const focusable = modalRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      focusable?.[0]?.focus();
    }, [isOpen]);
  • Close the modal when the user presses the Escape key.
    useEffect(() => {
      function handleKey(e) {
        if (e.key === "Escape") onClose();
      }
      if (isOpen) document.addEventListener("keydown", handleKey);
      return () => document.removeEventListener("keydown", handleKey);
    }, [isOpen, onClose]);
  • Prevent body scroll while the modal is open.
    useEffect(() => {
      document.body.style.overflow = isOpen ? "hidden" : "";
      return () => { document.body.style.overflow = ""; };
    }, [isOpen]);

Event Bubbling Through Portals

  • Events bubble from portals to ancestors in the React component tree.
    function Parent() {
      const handleClick = (e) => {
        if (e.target.closest(".modal")) {
          console.log("Modal clicked");
        }
      };
      
      return (
        <div onClick={handleClick}>
          <Modal>Content</Modal>
        </div>
      );
    }
  • Event bubbling goes through the React tree, not the DOM tree.
  • This allows parent components to handle events from portaled children.
  • Useful for closing modals when clicking outside.
  • Use stopPropagation inside the portal to prevent bubbling to parent handlers.
    function Modal({ onClose, children }) {
      return createPortal(
        <div className="backdrop" onClick={onClose}>
          <div className="content" onClick={e => e.stopPropagation()}>
            {children}
          </div>
        </div>,
        document.getElementById("modal-root")
      );
    }

Common Portal Use Cases

  • Tooltips that overflow their containers and need body-level placement.
    function Tooltip({ content, children }) {
      return (
        <>
          {children}
          {createPortal(
            <div className="tooltip">{content}</div>,
            document.body
          )}
        </>
      );
    }
  • Dropdowns and autocomplete menus that escape overflow-hidden parents.
    function Dropdown({ isOpen, options }) {
      if (!isOpen) return null;
      
      return createPortal(
        <ul className="dropdown">
          {options.map(opt => <li key={opt}>{opt}</li>)}
        </ul>,
        document.body
      );
    }
  • Notifications and toasts floating above all other content.
    function Toast({ message }) {
      return createPortal(
        <div className="toast">{message}</div>,
        document.getElementById("toast-container")
      );
    }
  • Context menus that must appear at the cursor position on the page.
    function ContextMenu({ x, y, items, onClose }) {
      return createPortal(
        <ul className="context-menu" style={{ top: y, left: x }}>
          {items.map(item => (
            <li key={item.label} onClick={() => { item.action(); onClose(); }}>
              {item.label}
            </li>
          ))}
        </ul>,
        document.body
      );
    }
  • Lightboxes that render full-screen images above all page content.
    function Lightbox({ src, alt, onClose }) {
      return createPortal(
        <div className="lightbox" onClick={onClose}>
          <img src={src} alt={alt} />
        </div>,
        document.getElementById("portal-root")
      );
    }

Portal Best Practices

  • Always have a target element in your HTML.
    <body>
      <div id="root"></div>
      <div id="modal-root"></div>
      <div id="tooltip-root"></div>
    </body>
  • Clean up portals when components unmount.
    useEffect(() => {
      return () => {
        // Cleanup if needed
      };
    }, []);
  • Use z-index in CSS to layer portaled elements correctly.
    .modal-backdrop {
      z-index: 1000;
    }
  • Manage focus when opening a modal for keyboard accessibility.
    useEffect(() => {
      if (isOpen) {
        closeButtonRef.current?.focus();
      }
    }, [isOpen]);
  • Add role="dialog" and aria-modal="true" for screen reader support.
    createPortal(
      <div role="dialog" aria-modal="true" aria-labelledby="modal-title">
        <h2 id="modal-title">Confirm Delete</h2>
        {children}
      </div>,
      document.getElementById("modal-root")
    )

Tip: Use portals for any UI that needs to visually escape its parent container without needing absolute positioning tricks.

Warning: Event bubbling in portals goes through the React tree, not the DOM tree, so document event listeners won't catch portal events from within React.

React Memo and Lazy LoadingReact Props and Children