We use cookies to enhance your experience on the site
CodeWorlds

React Portals - Rendering Outside the Hierarchy

React Portals are a powerful feature that allows rendering components outside the normal DOM hierarchy while preserving all React properties such as event bubbling and context. This is an ideal solution for modals, tooltips, overlays, and other UI elements requiring special positioning.

What Are React Portals?

A Portal is a way to render children in a DOM node that exists outside the parent component's hierarchy. Despite the element being rendered in a different place in the DOM, it retains all React properties.

Basic Example

1import { createPortal } from 'react-dom';
2
3function Modal({ children, isOpen }) {
4  if (!isOpen) return null;
5
6  // Render modal in #modal-root element instead of the current hierarchy
7  return createPortal(
8    <div className="modal-overlay">
9      <div className="modal-content">
10        {children}
11      </div>
12    </div>,
13    document.getElementById('modal-root') // Target DOM node
14  );
15}
16
17// In HTML there must exist:
18// <div id="modal-root"></div>

Why Use Portals?

1// Problem with normal rendering
2function App() {
3  return (
4    <div style={{ position: 'relative', zIndex: 1, overflow: 'hidden' }}>
5      <Header />
6      <main>
7        {/* Modal might be clipped by overflow: hidden */}
8        <Modal>
9          <p>This modal might not be visible!</p>
10        </Modal>
11      </main>
12    </div>
13  );
14}
15
16// Solution with Portal
17function PortalModal({ children, isOpen }) {
18  if (!isOpen) return null;
19
20  // Render directly in body - outside CSS constraints
21  return createPortal(
22    <div className="modal-backdrop">
23      <div className="modal">
24        {children}
25      </div>
26    </div>,
27    document.body
28  );
29}

Advanced Patterns with Portals

1. Portal Provider Pattern

1// PortalProvider.jsx
2import { createContext, useContext, useState, useCallback } from 'react';
3import { createPortal } from 'react-dom';
4
5const PortalContext = createContext();
6
7export function PortalProvider({ children }) {
8  const [portals, setPortals] = useState(new Map());
9
10  const addPortal = useCallback((id, content, target = document.body) => {
11    setPortals(prev => new Map(prev).set(id, { content, target }));
12  }, []);
13
14  const removePortal = useCallback((id) => {
15    setPortals(prev => {
16      const newMap = new Map(prev);
17      newMap.delete(id);
18      return newMap;
19    });
20  }, []);
21
22  return (
23    <PortalContext.Provider value={{ addPortal, removePortal }}>
24      {children}
25      {Array.from(portals.entries()).map(([id, { content, target }]) =>
26        createPortal(
27          <div key={id} data-portal-id={id}>
28            {content}
29          </div>,
30          target
31        )
32      )}
33    </PortalContext.Provider>
34  );
35}
36
37export const usePortal = () => {
38  const context = useContext(PortalContext);
39  if (!context) {
40    throw new Error('usePortal must be used within PortalProvider');
41  }
42  return context;
43};

2. Custom Hook for Portals

1// usePortal.js
2import { useEffect, useRef, useState } from 'react';
3
4export function usePortal(id = 'portal') {
5  const [isClient, setIsClient] = useState(false);
6  const portalRef = useRef(null);
7
8  useEffect(() => {
9    setIsClient(true);
10
11    // Check if element already exists
12    let portalElement = document.getElementById(id);
13
14    // If it doesn't exist, create it
15    if (!portalElement) {
16      portalElement = document.createElement('div');
17      portalElement.id = id;
18      portalElement.setAttribute('data-portal', 'true');
19      document.body.appendChild(portalElement);
20    }
21
22    portalRef.current = portalElement;
23
24    // Cleanup - remove element if it was created by this hook
25    return () => {
26      if (portalElement && portalElement.getAttribute('data-created-by-hook')) {
27        document.body.removeChild(portalElement);
28      }
29    };
30  }, [id]);
31
32  return {
33    Portal: ({ children }) => {
34      if (!isClient || !portalRef.current) return null;
35
36      return createPortal(children, portalRef.current);
37    },
38    portalElement: portalRef.current
39  };
40}
41
42// Usage
43function MyComponent() {
44  const { Portal } = usePortal('my-custom-portal');
45  const [showModal, setShowModal] = useState(false);
46
47  return (
48    <div>
49      <button onClick={() => setShowModal(true)}>
50        Show modal
51      </button>
52
53      {showModal && (
54        <Portal>
55          <div className="modal-overlay" onClick={() => setShowModal(false)}>
56            <div className="modal-content" onClick={e => e.stopPropagation()}>
57              <h2>Modal via Portal</h2>
58              <button onClick={() => setShowModal(false)}>Close</button>
59            </div>
60          </div>
61        </Portal>
62      )}
63    </div>
64  );
65}

3. Portal with Dynamic Target Elements

1// DynamicPortal.jsx
2function DynamicPortal({
3  children,
4  targetSelector,
5  targetElement,
6  fallbackToBody = true
7}) {
8  const [target, setTarget] = useState(null);
9
10  useEffect(() => {
11    let targetNode = null;
12
13    if (targetElement) {
14      targetNode = targetElement;
15    } else if (targetSelector) {
16      targetNode = document.querySelector(targetSelector);
17    } else if (fallbackToBody) {
18      targetNode = document.body;
19    }
20
21    setTarget(targetNode);
22  }, [targetSelector, targetElement, fallbackToBody]);
23
24  if (!target) return null;
25
26  return createPortal(children, target);
27}
28
29// Usage example
30function App() {
31  return (
32    <div>
33      <div id="sidebar-portals"></div>
34      <div id="header-portals"></div>
35
36      <main>
37        <DynamicPortal targetSelector="#sidebar-portals">
38          <div>Content in sidebar</div>
39        </DynamicPortal>
40
41        <DynamicPortal targetSelector="#header-portals">
42          <div>Content in header</div>
43        </DynamicPortal>
44      </main>
45    </div>
46  );
47}

Practical Use Cases

1. Modal System

1// ModalSystem.jsx
2import { createContext, useContext, useState, useId } from 'react';
3import { createPortal } from 'react-dom';
4
5const ModalContext = createContext();
6
7export function ModalProvider({ children }) {
8  const [modals, setModals] = useState([]);
9
10  const openModal = (content, options = {}) => {
11    const id = Date.now().toString();
12    const modal = {
13      id,
14      content,
15      ...options
16    };
17
18    setModals(prev => [...prev, modal]);
19    return id;
20  };
21
22  const closeModal = (id) => {
23    setModals(prev => prev.filter(modal => modal.id !== id));
24  };
25
26  const closeAllModals = () => {
27    setModals([]);
28  };
29
30  return (
31    <ModalContext.Provider value={{ openModal, closeModal, closeAllModals }}>
32      {children}
33      {modals.length > 0 && createPortal(
34        <div className="modal-container">
35          {modals.map((modal, index) => (
36            <ModalOverlay
37              key={modal.id}
38              modal={modal}
39              zIndex={1000 + index}
40              onClose={() => closeModal(modal.id)}
41            />
42          ))}
43        </div>,
44        document.body
45      )}
46    </ModalContext.Provider>
47  );
48}
49
50function ModalOverlay({ modal, zIndex, onClose }) {
51  return (
52    <div
53      className="modal-overlay"
54      style={{ zIndex }}
55      onClick={modal.closeOnOverlayClick !== false ? onClose : undefined}
56    >
57      <div
58        className={`modal-content ${modal.className || ''}`}
59        onClick={e => e.stopPropagation()}
60        style={modal.style}
61      >
62        {modal.showCloseButton !== false && (
63          <button
64            className="modal-close"
65            onClick={onClose}
66            aria-label="Close modal"
67          >
68            ×
69          </button>
70        )}
71        {modal.content}
72      </div>
73    </div>
74  );
75}
76
77export const useModal = () => {
78  const context = useContext(ModalContext);
79  if (!context) {
80    throw new Error('useModal must be used within ModalProvider');
81  }
82  return context;
83};
84
85// Example components
86function ConfirmationModal({ message, onConfirm, onCancel }) {
87  return (
88    <div>
89      <h3>Confirmation</h3>
90      <p>{message}</p>
91      <div className="modal-actions">
92        <button onClick={onCancel}>Cancel</button>
93        <button onClick={onConfirm} className="primary">
94          Confirm
95        </button>
96      </div>
97    </div>
98  );
99}
100
101function useConfirmation() {
102  const { openModal, closeModal } = useModal();
103
104  return (message) => {
105    return new Promise((resolve) => {
106      const id = openModal(
107        <ConfirmationModal
108          message={message}
109          onConfirm={() => {
110            resolve(true);
111            closeModal(id);
112          }}
113          onCancel={() => {
114            resolve(false);
115            closeModal(id);
116          }}
117        />,
118        { closeOnOverlayClick: false }
119      );
120    });
121  };
122}

2. Tooltip System

1// TooltipSystem.jsx
2function TooltipProvider({ children }) {
3  const [tooltip, setTooltip] = useState(null);
4  const timeoutRef = useRef();
5
6  const showTooltip = (content, targetElement, options = {}) => {
7    clearTimeout(timeoutRef.current);
8
9    const rect = targetElement.getBoundingClientRect();
10    const position = calculatePosition(rect, options.position || 'top');
11
12    setTooltip({
13      content,
14      position,
15      ...options
16    });
17  };
18
19  const hideTooltip = (delay = 0) => {
20    if (delay > 0) {
21      timeoutRef.current = setTimeout(() => {
22        setTooltip(null);
23      }, delay);
24    } else {
25      setTooltip(null);
26    }
27  };
28
29  useEffect(() => {
30    return () => clearTimeout(timeoutRef.current);
31  }, []);
32
33  return (
34    <TooltipContext.Provider value={{ showTooltip, hideTooltip }}>
35      {children}
36      {tooltip && createPortal(
37        <div
38          className={`tooltip tooltip--${tooltip.position}`}
39          style={{
40            left: tooltip.position.x,
41            top: tooltip.position.y,
42            zIndex: 9999
43          }}
44        >
45          {tooltip.content}
46        </div>,
47        document.body
48      )}
49    </TooltipContext.Provider>
50  );
51}
52
53function calculatePosition(targetRect, position) {
54  const tooltipSize = { width: 200, height: 40 }; // Approximate
55
56  const positions = {
57    top: {
58      x: targetRect.left + targetRect.width / 2 - tooltipSize.width / 2,
59      y: targetRect.top - tooltipSize.height - 8
60    },
61    bottom: {
62      x: targetRect.left + targetRect.width / 2 - tooltipSize.width / 2,
63      y: targetRect.bottom + 8
64    },
65    left: {
66      x: targetRect.left - tooltipSize.width - 8,
67      y: targetRect.top + targetRect.height / 2 - tooltipSize.height / 2
68    },
69    right: {
70      x: targetRect.right + 8,
71      y: targetRect.top + targetRect.height / 2 - tooltipSize.height / 2
72    }
73  };
74
75  return positions[position] || positions.top;
76}
77
78// Hook for easy usage
79function useTooltip() {
80  const { showTooltip, hideTooltip } = useContext(TooltipContext);
81
82  return {
83    show: (content, targetRef, options) => {
84      if (targetRef.current) {
85        showTooltip(content, targetRef.current, options);
86      }
87    },
88    hide: hideTooltip
89  };
90}

3. Notification System

1// NotificationSystem.jsx
2function NotificationProvider({ children }) {
3  const [notifications, setNotifications] = useState([]);
4
5  const addNotification = (notification) => {
6    const id = Date.now().toString();
7    const newNotification = {
8      id,
9      type: 'info',
10      duration: 5000,
11      ...notification
12    };
13
14    setNotifications(prev => [...prev, newNotification]);
15
16    // Auto-remove after specified time
17    if (newNotification.duration > 0) {
18      setTimeout(() => {
19        removeNotification(id);
20      }, newNotification.duration);
21    }
22
23    return id;
24  };
25
26  const removeNotification = (id) => {
27    setNotifications(prev => prev.filter(n => n.id !== id));
28  };
29
30  return (
31    <NotificationContext.Provider value={{ addNotification, removeNotification }}>
32      {children}
33      {createPortal(
34        <div className="notifications-container">
35          {notifications.map(notification => (
36            <NotificationItem
37              key={notification.id}
38              notification={notification}
39              onRemove={() => removeNotification(notification.id)}
40            />
41          ))}
42        </div>,
43        document.body
44      )}
45    </NotificationContext.Provider>
46  );
47}
48
49function NotificationItem({ notification, onRemove }) {
50  const [isVisible, setIsVisible] = useState(false);
51  const [isLeaving, setIsLeaving] = useState(false);
52
53  useEffect(() => {
54    // Entry animation
55    setTimeout(() => setIsVisible(true), 10);
56  }, []);
57
58  const handleRemove = () => {
59    setIsLeaving(true);
60    setTimeout(onRemove, 300); // Exit animation duration
61  };
62
63  return (
64    <div
65      className={`notification notification--${notification.type} ${
66        isVisible && !isLeaving ? 'notification--visible' : ''
67      } ${isLeaving ? 'notification--leaving' : ''}`}
68    >
69      <div className="notification-content">
70        {notification.title && (
71          <h4 className="notification-title">{notification.title}</h4>
72        )}
73        <p className="notification-message">{notification.message}</p>
74      </div>
75
76      {notification.actions && (
77        <div className="notification-actions">
78          {notification.actions.map((action, index) => (
79            <button
80              key={index}
81              onClick={() => {
82                action.handler();
83                if (action.closeOnClick !== false) {
84                  handleRemove();
85                }
86              }}
87              className={`notification-action ${action.className || ''}`}
88            >
89              {action.label}
90            </button>
91          ))}
92        </div>
93      )}
94
95      <button
96        className="notification-close"
97        onClick={handleRemove}
98        aria-label="Close notification"
99      >
100        ×
101      </button>
102    </div>
103  );
104}
105
106// Hook for notifications
107function useNotifications() {
108  const { addNotification } = useContext(NotificationContext);
109
110  return {
111    success: (message, options) => addNotification({
112      type: 'success',
113      message,
114      ...options
115    }),
116    error: (message, options) => addNotification({
117      type: 'error',
118      message,
119      duration: 0, // Don't auto-dismiss
120      ...options
121    }),
122    warning: (message, options) => addNotification({
123      type: 'warning',
124      message,
125      ...options
126    }),
127    info: (message, options) => addNotification({
128      type: 'info',
129      message,
130      ...options
131    })
132  };
133}

Event Bubbling in Portals

Event Behavior

1function PortalEventExample() {
2  const [count, setCount] = useState(0);
3
4  const handleParentClick = () => {
5    console.log('Parent clicked');
6    setCount(c => c + 1);
7  };
8
9  const handlePortalClick = (e) => {
10    console.log('Portal clicked');
11    // Event will "bubble" to parent even though Portal is outside DOM
12    // e.stopPropagation(); // Stops bubbling
13  };
14
15  return (
16    <div onClick={handleParentClick} className="parent">
17      <p>Clicks: {count}</p>
18      <p>Click the button in the portal - event will bubble to parent!</p>
19
20      {createPortal(
21        <div className="portal-content">
22          <button onClick={handlePortalClick}>
23            Button in Portal
24          </button>
25        </div>,
26        document.body
27      )}
28    </div>
29  );
30}

Context Through Portals

1// Context works normally through Portals
2const ThemeContext = createContext();
3
4function App() {
5  return (
6    <ThemeContext.Provider value={{ theme: 'dark' }}>
7      <div className="app">
8        <MainContent />
9
10        {/* Portal has access to ThemeContext */}
11        {createPortal(
12          <PortalComponent />,
13          document.getElementById('portal-root')
14        )}
15      </div>
16    </ThemeContext.Provider>
17  );
18}
19
20function PortalComponent() {
21  const { theme } = useContext(ThemeContext); // Works!
22
23  return (
24    <div className={`portal-component theme-${theme}`}>
25      I have access to context: {theme}
26    </div>
27  );
28}

Best Practices

1. Lifecycle Management

1function CleanPortal({ children, targetId }) {
2  const [portalTarget, setPortalTarget] = useState(null);
3
4  useEffect(() => {
5    // Create target element if it doesn't exist
6    let target = document.getElementById(targetId);
7    let shouldCleanup = false;
8
9    if (!target) {
10      target = document.createElement('div');
11      target.id = targetId;
12      document.body.appendChild(target);
13      shouldCleanup = true;
14    }
15
16    setPortalTarget(target);
17
18    // Cleanup
19    return () => {
20      if (shouldCleanup && target.parentNode) {
21        target.parentNode.removeChild(target);
22      }
23    };
24  }, [targetId]);
25
26  if (!portalTarget) return null;
27
28  return createPortal(children, portalTarget);
29}

2. Accessibility for Portals

1function AccessibleModal({ children, isOpen, onClose, title }) {
2  const modalRef = useRef();
3  const previousFocusRef = useRef();
4
5  useEffect(() => {
6    if (isOpen) {
7      // Save currently focused element
8      previousFocusRef.current = document.activeElement;
9
10      // Move focus to modal
11      if (modalRef.current) {
12        modalRef.current.focus();
13      }
14
15      // Lock scroll on body
16      document.body.style.overflow = 'hidden';
17
18      // Handle ESC key
19      const handleEscape = (e) => {
20        if (e.key === 'Escape') {
21          onClose();
22        }
23      };
24
25      document.addEventListener('keydown', handleEscape);
26
27      return () => {
28        document.removeEventListener('keydown', handleEscape);
29        document.body.style.overflow = '';
30
31        // Restore focus
32        if (previousFocusRef.current) {
33          previousFocusRef.current.focus();
34        }
35      };
36    }
37  }, [isOpen, onClose]);
38
39  if (!isOpen) return null;
40
41  return createPortal(
42    <div
43      className="modal-overlay"
44      onClick={onClose}
45      role="dialog"
46      aria-modal="true"
47      aria-labelledby="modal-title"
48    >
49      <div
50        ref={modalRef}
51        className="modal-content"
52        onClick={e => e.stopPropagation()}
53        tabIndex={-1}
54      >
55        <h2 id="modal-title">{title}</h2>
56        {children}
57
58        {/* Focus trap */}
59        <button
60          style={{ position: 'absolute', left: '-9999px' }}
61          onFocus={() => modalRef.current?.focus()}
62        />
63      </div>
64    </div>,
65    document.body
66  );
67}

3. Performance optimization

1// Lazy Portal - loads only when needed
2function LazyPortal({ children, isActive, targetId }) {
3  const [portalRoot, setPortalRoot] = useState(null);
4
5  useEffect(() => {
6    if (isActive && !portalRoot) {
7      const root = document.getElementById(targetId) || document.body;
8      setPortalRoot(root);
9    }
10  }, [isActive, targetId, portalRoot]);
11
12  // Render only when active and when we have a target
13  if (!isActive || !portalRoot) {
14    return null;
15  }
16
17  return createPortal(children, portalRoot);
18}
19
20// Memoized Portal for frequent re-renders
21const MemoizedPortal = memo(function MemoizedPortal({
22  children,
23  targetId,
24  shouldUpdate
25}) {
26  const portalTarget = useMemo(() => {
27    return document.getElementById(targetId) || document.body;
28  }, [targetId]);
29
30  return createPortal(children, portalTarget);
31}, (prevProps, nextProps) => {
32  // Custom comparison - re-render only when shouldUpdate changes
33  return prevProps.shouldUpdate === nextProps.shouldUpdate;
34});

React Portals are an extremely useful tool for creating advanced user interfaces. They enable rendering components outside the normal DOM hierarchy while preserving all React properties. The key to success is proper lifecycle management, accessibility, and performance.

Go to CodeWorlds