Skip to main content

Що таке портал у React

React Portal рендерить дочірні елементи в DOM-вузол поза ієрархією батьківського компонента, залишаючи їх при цьому у React-дереві - з повним доступом до стану, ефектів і контексту.

Теорія

TL;DR

  • Portal як доставка листа: вміст (твій JSX) надходить на іншу адресу DOM, але зв'язок з відправником (React-компонентом) не розривається.
  • Головне: portal обходить батьківський CSS (z-index, overflow:hidden), не втрачаючи стан і контекст.
  • React зберігає fiber-вузол під компонентом, що викликав portal, тому хуки, ефекти і провайдери контексту працюють як завжди.
  • Використовуй, коли дочірній елемент має виходити за межі CSS-контейнера. Для звичайного вкладення portal не потрібен.

Швидкий приклад

jsx
import { createPortal } from 'react-dom'; import { useState } from 'react'; function DropdownMenu() { const [open, setOpen] = useState(false); return ( <> <button onClick={() => setOpen(true)}>Меню</button> {open && createPortal( <div style={{ position: 'absolute', background: 'white', border: '1px solid #ccc' }}> <button onClick={() => setOpen(false)}>Закрити</button> </div>, document.getElementById('dropdown-root') // рендериться поза #root )} </> ); }

Дропдаун з'являється поверх батьківського елемента з overflow:hidden, стан залишається синхронізованим, а події кліку спливають через React-дерево.

Ключова різниця від звичайного рендерингу

Portal відокремлює лише фінальний DOM-вивід від батьківського піддерева. React зберігає fiber-вузол під компонентом, що його викликав, тому стан, ефекти і провайдери контексту працюють без змін. Змінюється лише те, де відбувається фінальне розміщення в DOM.

Саме тому modal всередині контейнера з transform: rotateX(10deg) зазвичай зміщується або обрізається. CSS stacking context утримує його в межах. Portal повністю виходить за ці межі.

Коли використовувати

  • Modal поверх батьківського елемента з z-index: 10 - portal на body, рендеринг з z-index: 9999.
  • Tooltip всередині CSS-трансформованого контейнера - portal виходить за межі transform origin.
  • Дропдаун у комірці таблиці з overflow: hidden - portal на document.body уникає обрізання.
  • Стек нотифікацій на краю екрана - portal зберігає незалежність від зміщень у layout.

Пропускай portal, якщо дочірній елемент вписується в стилі батька або потребує позиціонування відносно нього.

Як це працює зсередини

Рекончайлер React створює fiber для portal-дочірнього елемента під компонентом, що його викликає, але встановлює containerInfo на цільовий DOM-вузол. Під час фази commit DOM-мутації надходять до цього вузла через container.appendChild. Події продовжують проходити через синтетичну систему подій React незалежно від розташування в DOM, тому onClick спливає по React-дереву, а не по DOM-дереву.

Hydration mismatch при SSR трапляється значно частіше, ніж проблема з z-index. Проблема з z-index видна відразу. Помилка гідрації проявляється пізніше, вже в продакшені.

Типові помилки

Portal на неіснуючий вузол:

jsx
createPortal(<div>Hi</div>, document.getElementById('missing')); // null - кидає помилку в React 18

Рішення: створи елемент всередині useEffect і додай його до document.body.

Плутанина між DOM-батьком і React-батьком:

jsx
document.getElementById('modal-root').children[0].parentNode === document.getElementById('app-root'); // false

DOM-ієрархія і React-ієрархія - різні речі. Обхід DOM не відображає дерево компонентів.

Hydration mismatch при SSR:

jsx
// Сервер не має #portal-root, клієнт намагається portal - React 18 кидає помилку createPortal(<div>content</div>, document.getElementById('portal-root'));

Рішення: захищай через mounted стан, що виставляється в useEffect. На сервері повертай прихований placeholder.

Немає cleanup при демонтажі: Якщо вручну створюєш контейнер у useEffect, прибирай його в cleanup-функції. Інакше DOM-вузли накопичуються при кожному повторному монтуванні.

Де зустрічається

  • Material-UI (v5) - модали і дропдауни портуються на document.body для управління стекінгом.
  • Chakra UI - DefaultPortalManager додає елементи до кастомного #chakra-portal-root.
  • Headless UI - компоненти Menu і Combobox використовують portal для focus trap поза батьківським елементом.
  • React Bootstrap - Popover і Tooltip через portal виходять за межі таблиць.

Питання на співбесіді

Q: Чи зберігає portal контекст React від батьківських провайдерів?
A: Так. Контекст читається з fiber-дерева, а не з DOM. Portal-дочірній елемент бачить усіх батьківських провайдерів, навіть перебуваючи в іншому DOM-вузлі.

Q: Як спливання подій (event bubbling) працює через portal?
A: Нативні DOM-події спливають по DOM-дереву. Синтетичні події React проходять через React root. Тому onClick на portal-елементі спливає через React-предків, а не через DOM-предків.

Q: Яка різниця між portal і Shadow DOM?
A: Portal зберігає глобальні стилі і React-події. Shadow DOM створює повністю ізольовану границю стилів і подій. Shadow DOM потрібен для справжньої ізоляції сторонніх віджетів; portal - для виходу за межі CSS-обмежень при роботі в React-дереві.

Q: Як працювати з portalами при SSR у Next.js?
A: Використовуй прапорець mounted, що виставляється в useEffect. Під час серверного рендерингу повертай прихований placeholder. Це запобігає hydration mismatch у React 18 strict mode.

Приклади

jsx
import { createPortal } from 'react-dom'; import { useEffect } from 'react'; function ConfirmationModal({ isOpen, onConfirm, onCancel, message }) { useEffect(() => { if (isOpen) document.body.style.overflow = 'hidden'; // блокуємо скрол return () => { document.body.style.overflow = ''; }; }, [isOpen]); if (!isOpen) return null; return createPortal( <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 9999 }}> <div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%,-50%)' }}> <p>{message}</p> <button onClick={onConfirm}>Підтвердити</button> <button onClick={onCancel}>Скасувати</button> </div> </div>, document.body ); }

Modal рендериться прямо на document.body, тому жоден батьківський overflow, transform або z-index не може його обрізати. Блокування скролу запобігає прокрутці фону, поки modal відкритий. Це той самий патерн, що використовують react-modal і Material-UI Dialog.

SSR-безпечний portal

jsx
import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; function ClientOnlyPortal({ children }) { const [mounted, setMounted] = useState(false); const containerRef = useRef(null); useEffect(() => { containerRef.current = document.getElementById('portal-root'); setMounted(true); }, []); if (!mounted) return <div style={{ visibility: 'hidden' }}>{children}</div>; return createPortal(children, containerRef.current); }

Без перевірки mounted сервер рендерить одне, клієнт - інше. React 18 кидає hydration error. Прихований placeholder зберігає DOM-структуру однаковою на сервері і клієнті, тому React може виконати reconciliation без помилок.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?