React Portals: как работают порталы, bubbling событий и где они реально нужны
Разбираем React Portals на уровне production: createPortal, bubbling событий, stacking context, accessibility, SSR, позиционирование и ошибки на собеседовании.
- Введение
- Что такое React Portal
- Где портал действительно нужен
- Архитектура: React tree против DOM tree
- Как работает bubbling событий через портал
- Таблица: когда портал оправдан, а когда нет
- SSR, hydration и PortalHost
- Accessibility: где портал не спасает
- Production pitfalls
- 1. Портал решили добавить, но outside click спроектировали по старой DOM-модели
- 2. Layering починили, а позиционирование сломали
- 3. Cleanup глобальных эффектов написан несимметрично
- Разбор производительности
- Практики, которые реально работают
- Держите отдельные host-контейнеры для разных слоев
- Разделяйте задачу слоя и задачу состояния
- Делайте overlay-подсистему предсказуемой
- Тестируйте клавиатуру и вложенные overlay отдельно
- Частые ошибки
- Использовать портал по умолчанию для любого выпадающего слоя
- Думать, что z-index решает все
- Игнорировать различие между React bubbling и DOM bubbling
- Считать, что портал автоматически делает компонент доступным
- Как отвечать на собеседовании
- FAQ
- Портал создает новый React root
- Почему context внутри портала продолжает работать
- Нужен ли портал для tooltip всегда
- Почему портал часто идет вместе с refs и imperative API
- Что важнее запомнить на интервью
- Итоги
React Portals редко ломаются на синтаксисе. createPortal запоминается быстро. Ошибки начинаются позже, когда команда считает, что портал автоматически решает overlay-архитектуру, доступность и обработку событий. Для базовой модели событий полезно держать рядом разбор Synthetic Events в React.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Введение
Портал в React решает узкую, но важную задачу: позволяет отрисовать узел в другом месте DOM, не вырывая компонент из текущего React-дерева. Именно поэтому модалка, tooltip или dropdown могут визуально жить возле body, но логически оставаться дочерними для страницы, откуда они были открыты.
На собеседовании эта тема быстро отделяет пересказ документации от инженерного понимания. Слабый ответ звучит так: "порталы нужны для модалок". Сильный начинается с ограничения среды: родительский контейнер режет overlay через overflow: hidden, создает новый stacking context или ломает позиционирование всплывающего слоя. После этого уже объясняют, почему портал помогает и какие проблемы он не закрывает.
Что такое React Portal
createPortal рендерит JSX в другой DOM-контейнер, но не создает новый React root. Компонент по-прежнему живет в том же дереве, получает тот же context, видит те же props и участвует в том же жизненном цикле.
import { createPortal } from "react-dom";
type ModalProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
};
export function Modal({ open, onClose, children }: ModalProps) {
const host = document.getElementById("modal-root");
if (!open || !host) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-content"
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>,
host
);
}
Из этого следуют четыре свойства, которые часто спрашивают на интервью:
contextвнутри портала продолжает работать;- локальный и родительский state никуда не исчезают;
- React-события всплывают по React tree;
- перенос в другой DOM-контейнер не отменяет необходимость управлять фокусом и побочными эффектами.
Если нужно освежить разницу между логическим деревом React и физическим DOM-путем рендера, рядом хорошо читается материал про перерисовки React-компонентов.
Где портал действительно нужен
Портал стоит применять не по привычке, а по ограничению среды. Самые частые случаи такие:
- модалка или drawer открываются внутри контейнера с
overflow: hiddenи начинают обрезаться; - dropdown выходит за границы таблицы, sticky-header или scroll-контейнера;
- tooltip визуально должен быть поверх соседних секций, но его затирает новый stacking context;
- глобальные слои вроде toast, command palette или confirm dialog должны жить рядом с верхним слоем документа, а не внутри произвольной страницы.
Если ничего из этого нет, портал нередко только усложняет код. Обычный dropdown внутри простого layout не обязан рендериться через body. Иначе вы бесплатно получаете более сложный outside click, отдельную логику позиционирования и лишние ветки тестов.
Архитектура: React tree против DOM tree
Это центральная идея всей темы. Портал меняет положение DOM-узла, но не меняет место компонента в React-дереве.
Схема выглядит так:
PageрендеритProfileDialog.ProfileDialogвызываетcreatePortal.- Разметка диалога оказывается в
#modal-root. - В React
ProfileDialogвсе равно остается дочерним дляPage.
Практический смысл у этой схемы большой:
ThemeContextилиAuthContextдоступны внутри диалога без дополнительного bridge;- родитель может закрыть портал через обычный callback prop;
- событие из портала способно дойти до React-обработчика на странице;
- баги часто рождаются там, где разработчик ориентируется только на
Node.containsи забывает про React tree.
В production полезно рассматривать overlay-подсистему как отдельный слой архитектуры:
| Компонент системы | Роль | Что не должен решать |
|---|---|---|
PortalHost | Дает стабильный DOM-контейнер для слоя | Бизнес-логику открытия |
| Overlay-компонент | Рендерит контент, backdrop, анимацию | Глобальный стек окон |
| Overlay manager | Управляет стеком, scroll lock, z-index policy | Детали конкретной формы внутри окна |
| Positioning layer | Считает координаты dropdown и tooltip | Состояние данных экрана |
| Focus management | Возврат фокуса, trap, Escape | Визуальную верстку |
Такое разделение нужно потому, что портал решает только место в DOM. Как только им пытаются заменить modal manager целиком, появляется хрупкая связность.
Как работает bubbling событий через портал
Один из самых популярных вопросов: почему клик внутри портала вызывает обработчик у родителя, хотя в DOM они живут в разных местах. Ответ: React обрабатывает всплытие по своему дереву компонентов.
import { createPortal } from "react-dom";
function Page() {
return (
<section onClick={() => console.log("page click")}>
<Dialog />
</section>
);
}
function Dialog() {
const host = document.getElementById("modal-root");
if (!host) return null;
return createPortal(
<button onClick={() => console.log("dialog button click")}>
Save
</button>,
host
);
}
При клике вы увидите сначала dialog button click, потом page click. Для React это ожидаемое поведение.
Именно здесь часто ломается логика outside click. Команда проверяет только rootRef.current.contains(event.target as Node), а затем удивляется, почему nested dropdown через портал закрывает родительскую модалку. Для сложных сценариев лучше проектировать "внутреннюю область" явно: знать все portal-host контейнеры, слушать события на capture-фазе и не путать визуальную вложенность с логической.
Таблица: когда портал оправдан, а когда нет
| Сценарий | Без портала | С порталом | Основной критерий выбора | Ограничение |
|---|---|---|---|---|
| Простая модалка в небольшом приложении | Иногда возможно | Обычно надежнее | clipping и верхний слой | Нужны focus trap и scroll lock |
| Dropdown внутри scroll-контейнера | Часто нестабильно | Часто лучше | обрезание и позиционирование | Понадобится расчет координат |
| Tooltip в обычной карточке без clipping | Обычно достаточно | Часто избыточно | простота реализации | Портал добавит сложность без выгоды |
| Toast или command palette | Неудобно | Почти всегда лучше | глобальный слой UI | Нужен единый manager |
| Контекстное меню в таблице с sticky-header | Хрупко | Обычно лучше | stacking context и scroll | Нужно синхронизировать scroll и resize |
| Поповер в форме без особых CSS-ограничений | Достаточно | Необязательно | стоимость сопровождения | Портал может усложнить outside click |
Рабочее правило простое: сначала ищите реальное ограничение layout, потом добавляйте портал. Не наоборот.
SSR, hydration и PortalHost
В SSR-приложении нельзя предполагать, что document доступен на первом рендере. Поэтому прямой вызов document.getElementById на сервере либо упадет, либо даст несовпадение между серверным и клиентским выводом.
Надежнее выделить небольшой слой для host-контейнера:
import { useEffect, useState } from "react";
function usePortalHost(id: string) {
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.getElementById(id));
}, [id]);
return host;
}
Такой подход не делает SSR "бесплатным", но хотя бы убирает ранний доступ к document. Дальше остаются еще два вопроса:
- что увидит пользователь до монтирования хоста;
- нужно ли скрывать или откладывать overlay до клиента.
Если проект уже использует серверный рендер, эту тему полезно связывать с React Server Components и общими проблемами гидратации.
Accessibility: где портал не спасает
Частая ошибка звучит так: "мы вынесли модалку в портал, значит все уже корректно". Нет. Портал не управляет фокусом, не расставляет ARIA-атрибуты и не блокирует доступ к фону.
Минимальный каркас для модального окна обычно включает:
role="dialog"илиrole="alertdialog";aria-modal="true";- перенос фокуса внутрь при открытии;
- возврат фокуса на триггер при закрытии;
- закрытие по
Escape, если сценарий это допускает; - блокировку или изоляцию фонового контента.
import { useEffect, useRef } from "react";
function AccessibleDialog({
open,
titleId,
onClose,
}: {
open: boolean;
titleId: string;
onClose: () => void;
}) {
const dialogRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
const previousActiveElement = document.activeElement as HTMLElement | null;
dialogRef.current?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previousActiveElement?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
>
...
</div>
);
}
Если в проекте много сложных фокус-сценариев, тема порталов хорошо стыкуется с forwardRef и useImperativeHandle: оба инструмента часто встречаются в одной overlay-подсистеме.
Прокачай React за 7 дней
20 вопросов и разборов по React Hooks.
Production pitfalls
1. Портал решили добавить, но outside click спроектировали по старой DOM-модели
Симптомы:
- клик по dropdown внутри модалки закрывает модалку;
- backdrop срабатывает даже при клике по контенту;
- баг воспроизводится только в portal-версии компонента.
Последствия:
- пользователь теряет введенные данные;
- ломаются цепочки вложенных overlay;
- аналитика получает лишние закрытия и повторные открытия.
Профилактика: описывать все внутренние области явно и проверять не только один корневой contains, а весь набор portal-host контейнеров.
2. Layering починили, а позиционирование сломали
Портал убирает clipping, но не вычисляет координаты. Поэтому tooltip или popover начинают "отрываться" от якоря при scroll, zoom и resize.
Признаки в продукте:
- меню смещается на несколько пикселей после прокрутки;
- на мобильных поповер уезжает за край экрана;
- sticky-header и nested scroll контейнеры ломают привязку к anchor.
Профилактика: проектировать пару "портал + positioning engine", а не надеяться, что одного createPortal достаточно.
3. Cleanup глобальных эффектов написан несимметрично
Overlay почти всегда тянет за собой keydown, scroll lock, изменение document.body.style.overflow, иногда ResizeObserver и подписки на scroll.
Если cleanup забыли или реализовали несимметрично:
- страница остается заблокированной после закрытия модалки;
Escapeначинает срабатывать несколько раз;- после длинной сессии всплывают трудноуловимые утечки слушателей.
Эта проблема особенно заметна в Strict Mode, поэтому рядом полезно помнить разбор React Strict Mode.
Разбор производительности
Сам портал редко является узким местом. Узкое место обычно находится вокруг него:
- тяжелый mount большого редактора или таблицы внутри модалки;
- пересчет позиции на каждый
scroll; - глобальный overlay-manager, который ререндерит половину приложения;
- каскадные эффекты при открытии и закрытии.
Когда оптимизация оправдана:
- на странице одновременно живут несколько dropdown и tooltip;
- пересчет координат идет часто и затрагивает layout;
- внутри портала рендерится тяжелый контент: редактор, график, длинный список;
- видно деградацию
p95времени открытия overlay или лаги при вводе.
Когда оптимизация преждевременна:
- у вас один простой confirm dialog;
- нет жалоб на scroll, resize и смещение;
- основная цена сидит в лишних ререндерах страницы, а не в портале.
Практически полезнее сначала профилировать дерево обновлений. Если overlay ререндерит слишком широкую ветку, поможет не мемоизация ради мемоизации, а правильная граница состояния. На эту тему хорошо ложится статья про React.memo, useMemo и useCallback.
Практики, которые реально работают
Держите отдельные host-контейнеры для разных слоев
Типичный набор:
#modal-root#tooltip-root#toast-root
Это упрощает стили, диагностику и контроль порядка слоев.
Разделяйте задачу слоя и задачу состояния
Портал отвечает за размещение в DOM. Он не обязан автоматически превращаться в источник глобального состояния окна. Чем ближе состояние открытия к месту использования, тем проще сопровождение.
Делайте overlay-подсистему предсказуемой
Нужны понятные правила:
- кто открывает и закрывает слой;
- как устроен стек нескольких окон;
- кто возвращает фокус;
- кто отвечает за блокировку фона;
- как работает деградация при отсутствии host-контейнера.
Тестируйте клавиатуру и вложенные overlay отдельно
Минимальный набор проверок:
- открытие и закрытие по клавиатуре;
- возврат фокуса на исходный триггер;
- корректная работа nested dropdown внутри модалки;
- scroll и resize без потери позиции;
- cleanup после unmount.
Частые ошибки
Использовать портал по умолчанию для любого выпадающего слоя
Если clipping и stacking context не мешают, обычный рендер почти всегда дешевле в сопровождении.
Думать, что z-index решает все
Если проблема вызвана новым stacking context или обрезанием по overflow, увеличение z-index дочернего узла не спасет.
Игнорировать различие между React bubbling и DOM bubbling
Из-за этого ломаются обработчики закрытия, аналитика и вложенные interactive-компоненты.
Считать, что портал автоматически делает компонент доступным
Без focus management и корректной ARIA-разметки модалка остается неудобной и для клавиатуры, и для screen reader.
Как отвечать на собеседовании
Хороший короткий ответ можно построить в четыре шага:
- React Portal рендерит часть React-дерева в другой DOM-контейнер, не создавая новый React root.
- Он нужен там, где текущий контейнер ломает overlay через
overflow, stacking context или позиционирование. context, state и всплытие React-событий сохраняются, потому что компонент остается в том же React tree.- Портал не решает автоматически focus trap, outside click, scroll lock и позиционирование. Эти части надо проектировать отдельно.
Если интервьюер углубляется, добавляйте компромисс: портал полезен, когда действительно есть проблема слоя. Если такой проблемы нет, он может быть лишним и даже ухудшить поддержку компонента.
Подготовьтесь к React-собеседованию на вопросах про overlay, события и архитектуру UI
Разберите порталы, ререндеры, refs и сложные production-кейсы в формате технического интервью с разбором сильных и слабых ответов.
FAQ
Портал создает новый React root
Нет. Он меняет только место рендера в DOM. С точки зрения React это все еще та же ветка компонентов.
Почему context внутри портала продолжает работать
Потому что источник context определяется положением в React tree, а не физическим положением DOM-узла в документе.
Нужен ли портал для tooltip всегда
Нет. Если tooltip не страдает от clipping, stacking context и сложного позиционирования, рендер рядом с якорем проще.
Почему портал часто идет вместе с refs и imperative API
Потому что overlay-компонентам часто нужны фокус, возврат фокуса, измерение DOM и программное открытие. Но это отдельная задача, не свойство портала самого по себе.
Что важнее запомнить на интервью
Главная мысль такая: портал переносит узел в DOM, но не выносит компонент из React-дерева. Из этого уже следуют context, bubbling событий и большинство production-нюансов.
Итоги
React Portals полезны не как "специальный API для модалок", а как способ разорвать ограничения текущего DOM-контейнера, не разрывая логику React-компонента. Они хорошо решают проблемы слоя, но не заменяют архитектуру overlay-подсистемы.
Зрелый инженерный подход выглядит так: сначала найти реальное ограничение layout, затем вынести слой через портал, после этого отдельно спроектировать события, позиционирование, доступность и cleanup побочных эффектов. Именно эта последовательность обычно отличает устойчивое production-решение от хрупкой модалки, которая работает только в демо.
Больше вопросов в Telegram
Ежедневные разборы и реальные кейсы с интервью.
Автор
Lexicon Team
Читайте также
frontend
Event Loop и React: как связаны и почему это влияет на рендер
Разбираем связь Event Loop и React без упрощений: macrotask, microtask, batching, commit, useEffect, scheduler, производительность и типичные ошибки на интервью.
frontend
20 задач по React с разбором: что реально проверяют на собеседовании и в production
Собрали 20 задач по React с разбором: ререндеры, keys, формы, refs, Suspense, Context, SSR, оптимизация и типичные ошибки, которые всплывают на интервью.
frontend
Synthetic Events в React: как работает система событий
Разбираем Synthetic Events в React: делегирование, bubbling/capturing, приоритеты обновлений, интеграция с native events и production-ошибки.