React Portals: как работают порталы, bubbling событий и где они реально нужны

Разбираем React Portals на уровне production: createPortal, bubbling событий, stacking context, accessibility, SSR, позиционирование и ошибки на собеседовании.

10 марта 2026 г.18 минLexicon Team

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-дереве.

Схема выглядит так:

  1. Page рендерит ProfileDialog.
  2. ProfileDialog вызывает createPortal.
  3. Разметка диалога оказывается в #modal-root.
  4. В 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.

Как отвечать на собеседовании

Хороший короткий ответ можно построить в четыре шага:

  1. React Portal рендерит часть React-дерева в другой DOM-контейнер, не создавая новый React root.
  2. Он нужен там, где текущий контейнер ломает overlay через overflow, stacking context или позиционирование.
  3. context, state и всплытие React-событий сохраняются, потому что компонент остается в том же React tree.
  4. Портал не решает автоматически 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

Читайте также