Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке портал у React». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**React Portal** рендерить дочірні елементи в DOM-вузол поза ієрархією батьківського компонента, залишаючись у React-дереві. ```jsx import { createPortal } from 'react-dom'; function Modal({ children }) { return createPortal( <div className="modal">{children}</div>, document.body // рендериться поза батьківським DOM ); } ``` **Головне:** дочірній елемент виходить за межі батьківського CSS (overflow, z-index, transform), але зберігає повний доступ до стану, контексту і подій React.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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-вузол під компонентом, що його викликав, тому стан, ефекти і [провайдери контексту](/questions/what-is-context-in-react) працюють без змін. Змінюється лише те, де відбувається фінальне розміщення в 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](/questions/what-is-use-effect-in-react) і додай його до `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. ## Приклади ### Modal з блокуванням скролу ```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 без помилок.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.