Skip
Arish's avatar

32. React Portals


React Portals

Portals let you render children into a DOM node outside the parent component's DOM hierarchy.

Basic Portal

jsx
1import { createPortal } from 'react-dom'
2
3function Modal({ children, isOpen }) {
4  if (!isOpen) return null
5  
6  return createPortal(
7    <div className="modal-overlay">
8      <div className="modal-content">
9        {children}
10      </div>
11    </div>,
12    document.body  // Render to body, not parent
13  )
14}
15
16// Usage
17function App() {
18  const [showModal, setShowModal] = useState(false)
19  
20  return (
21    <div className="app">
22      <button onClick={() => setShowModal(true)}>Open Modal</button>
23      
24      <Modal isOpen={showModal}>
25        <h2>Modal Title</h2>
26        <p>Modal content here</p>
27        <button onClick={() => setShowModal(false)}>Close</button>
28      </Modal>
29    </div>
30  )
31}

Why Use Portals?

CSS Overflow Issues

jsx
1// Without portal - modal gets clipped
2<div style={{ overflow: 'hidden' }}>
3  <Modal>Content</Modal>  {/* Gets clipped! */}
4</div>
5
6// With portal - renders to body, no clipping
7<div style={{ overflow: 'hidden' }}>
8  <Modal>Content</Modal>  {/* Renders outside, visible! */}
9</div>

Z-Index Stacking

jsx
1// Portals escape z-index stacking contexts
2function Tooltip({ children, content }) {
3  const [show, setShow] = useState(false)
4  const [coords, setCoords] = useState({ x: 0, y: 0 })
5  const ref = useRef()
6  
7  const handleMouseEnter = () => {
8    const rect = ref.current.getBoundingClientRect()
9    setCoords({ x: rect.x, y: rect.bottom })
10    setShow(true)
11  }
12  
13  return (
14    <>
15      <span 
16        ref={ref}
17        onMouseEnter={handleMouseEnter}
18        onMouseLeave={() => setShow(false)}
19      >
20        {children}
21      </span>
22      
23      {show && createPortal(
24        <div 
25          className="tooltip"
26          style={{ position: 'fixed', left: coords.x, top: coords.y }}
27        >
28          {content}
29        </div>,
30        document.body
31      )}
32    </>
33  )
34}

Complete Modal Component

jsx
1import { createPortal } from 'react-dom'
2import { useEffect, useRef } from 'react'
3
4function Modal({ isOpen, onClose, title, children }) {
5  const overlayRef = useRef()
6  
7  // Handle escape key
8  useEffect(() => {
9    const handleEscape = (e) => {
10      if (e.key === 'Escape') onClose()
11    }
12    
13    if (isOpen) {
14      document.addEventListener('keydown', handleEscape)
15      document.body.style.overflow = 'hidden'
16    }
17    
18    return () => {
19      document.removeEventListener('keydown', handleEscape)
20      document.body.style.overflow = ''
21    }
22  }, [isOpen, onClose])
23  
24  // Handle click outside
25  const handleOverlayClick = (e) => {
26    if (e.target === overlayRef.current) {
27      onClose()
28    }
29  }
30  
31  if (!isOpen) return null
32  
33  return createPortal(
34    <div 
35      ref={overlayRef}
36      className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
37      onClick={handleOverlayClick}
38    >
39      <div 
40        className="bg-white rounded-lg shadow-xl max-w-md w-full m-4"
41        role="dialog"
42        aria-modal="true"
43      >
44        <div className="flex items-center justify-between p-4 border-b">
45          <h2 className="text-lg font-semibold">{title}</h2>
46          <button 
47            onClick={onClose}
48            className="text-gray-400 hover:text-gray-600"
49            aria-label="Close"
50          >
5152          </button>
53        </div>
54        <div className="p-4">
55          {children}
56        </div>
57      </div>
58    </div>,
59    document.body
60  )
61}
jsx
1function Dropdown({ trigger, children }) {
2  const [isOpen, setIsOpen] = useState(false)
3  const [position, setPosition] = useState({ top: 0, left: 0 })
4  const triggerRef = useRef()
5  
6  const handleOpen = () => {
7    const rect = triggerRef.current.getBoundingClientRect()
8    setPosition({
9      top: rect.bottom + window.scrollY,
10      left: rect.left + window.scrollX
11    })
12    setIsOpen(true)
13  }
14  
15  // Close on outside click
16  useEffect(() => {
17    if (!isOpen) return
18    
19    const handleClick = (e) => {
20      if (!triggerRef.current.contains(e.target)) {
21        setIsOpen(false)
22      }
23    }
24    
25    document.addEventListener('click', handleClick)
26    return () => document.removeEventListener('click', handleClick)
27  }, [isOpen])
28  
29  return (
30    <>
31      <div ref={triggerRef} onClick={handleOpen}>
32        {trigger}
33      </div>
34      
35      {isOpen && createPortal(
36        <div 
37          className="absolute bg-white shadow-lg rounded-md py-2 min-w-48 z-50"
38          style={{ top: position.top, left: position.left }}
39        >
40          {children}
41        </div>,
42        document.body
43      )}
44    </>
45  )
46}
47
48// Usage
49<Dropdown trigger={<button>Menu</button>}>
50  <a href="#" className="block px-4 py-2 hover:bg-gray-100">Profile</a>
51  <a href="#" className="block px-4 py-2 hover:bg-gray-100">Settings</a>
52  <a href="#" className="block px-4 py-2 hover:bg-gray-100">Logout</a>
53</Dropdown>

Portal Container

Create a dedicated portal container:

html
1<!-- index.html -->
2<body>
3  <div id="root"></div>
4  <div id="portal-root"></div>
5</body>
jsx
1function Modal({ children, isOpen }) {
2  if (!isOpen) return null
3  
4  const portalRoot = document.getElementById('portal-root')
5  
6  return createPortal(children, portalRoot)
7}

Event Bubbling

Events still bubble through React tree, not DOM tree:

jsx
1function App() {
2  const handleClick = () => console.log('App clicked')
3  
4  return (
5    <div onClick={handleClick}>
6      <Modal isOpen={true}>
7        <button>Click me</button>  {/* Still triggers App's onClick! */}
8      </Modal>
9    </div>
10  )
11}

Portals are essential for modals, tooltips, and overlays!