Що таке портал у React
React Portal рендерить дочірні елементи в DOM-вузол поза ієрархією батьківського компонента, залишаючи їх при цьому у React-дереві - з повним доступом до стану, ефектів і контексту.
Теорія
TL;DR
- Portal як доставка листа: вміст (твій JSX) надходить на іншу адресу DOM, але зв'язок з відправником (React-компонентом) не розривається.
- Головне: portal обходить батьківський CSS (z-index, overflow:hidden), не втрачаючи стан і контекст.
- React зберігає fiber-вузол під компонентом, що викликав portal, тому хуки, ефекти і провайдери контексту працюють як завжди.
- Використовуй, коли дочірній елемент має виходити за межі CSS-контейнера. Для звичайного вкладення portal не потрібен.
Швидкий приклад
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 на неіснуючий вузол:
createPortal(<div>Hi</div>, document.getElementById('missing')); // null - кидає помилку в React 18Рішення: створи елемент всередині useEffect і додай його до document.body.
Плутанина між DOM-батьком і React-батьком:
document.getElementById('modal-root').children[0].parentNode
=== document.getElementById('app-root'); // falseDOM-ієрархія і React-ієрархія - різні речі. Обхід DOM не відображає дерево компонентів.
Hydration mismatch при SSR:
// Сервер не має #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.
Приклади
Modal з блокуванням скролу
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
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 без помилок.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.