Порталы в React — телепорт для компонентов в любую точку DOM
Содержание
- Введение: зачем разрывать DOM-иерархию
- Суть проблемы: когда иерархия мешает
- ReactDOM.createPortal — синтаксис и принцип работы
- Реальный сценарий: рендеринг вне родителя
- Портал для глобальных UI-элементов
- Портал и события (важное отличие)
- Портал с контекстом
- Портал и SSR (Server Side Rendering)
- Множественные порталы (разные контейнеры)
- Частые ошибки новичков
- Итог (что мы узнали)
- Задание для закрепления (на 1–2 часа)
- Что дальше?
Цель урока: понять механизм порталов как способа рендеринга React-компонентов за пределами родительского DOM-дерева. Научиться использовать порталы для решения задач, выходящих за рамки обычной иерархии: модальные окна, всплывающие подсказки, нотификации и любые другие сценарии, где нужно разорвать DOM-связь, но сохранить React-связь.
Введение: зачем разрывать DOM-иерархию
В обычном React-приложении компоненты образуют строгое дерево: родитель → дочерний элемент. React монтирует дочерние компоненты внутрь DOM-элементов родителей. Это называется иерархическим рендерингом.
Проблема этого подхода
- Родительские CSS-стили (
overflow: hidden,z-index,transform) могут «запирать» дочерние элементы - Сложно создавать элементы, которые должны перекрывать родительские границы
- Некоторые компоненты логически принадлежат одному месту в React-дереве, но визуально должны отображаться в другом
Главная мысль: портал — это «телепорт». Ты оставляешь компонент в React-дереве (сохраняя контекст и события), но отправляешь его DOM-узел в любое другое место на странице.
Когда нужны порталы (не только «поверх всего»)
- Элементы, которые должны вырываться из CSS-ограничений родителя (
overflow: hidden,z-indexи т.д.) - Компоненты, которые логически принадлежат одному месту в React-дереве, но физически должны быть в другом
- Интеграция с сторонними DOM-библиотеками (карты, графики)
- Визуальные компоненты, которые должны перекрывать родительские границы (модалки, тултипы, дропдауны, нотификации)
Суть проблемы: когда иерархия мешает
Пример проблемы с CSS:
function App() {
return (
<div style={{ overflow: 'hidden', position: 'relative' }}>
{/* Родитель с overflow: hidden */}
<div style={{ position: 'absolute', top: '100px', left: '100px' }}>
{/* Дочерний элемент обрезается родителем */}
<Tooltip text="Подсказка" />
</div>
</div>
);
}
Решение через портал:
function Tooltip({ text }) {
return ReactDOM.createPortal(
<div style={{ position: 'fixed', top: '100px', left: '100px' }}>
{text}
</div>,
document.body // Рендерим в body, минуя родителя
);
}
ReactDOM.createPortal — синтаксис и принцип работы
Синтаксис:
ReactDOM.createPortal(children, domNode)
Важно: портал не меняет React-дерево:
childrenостаются частью React-дерева- Контекст продолжает работать
- События всплывают через React-дерево (не DOM-дерево!)
- Все хуки и жизненный цикл работают как обычно
Базовый пример:
import ReactDOM from 'react-dom';
function PortalExample() {
return ReactDOM.createPortal(
<div>
<h3>Я в портале!</h3>
<p>Я рендерюсь в другом DOM-узле, но остаюсь в React-дереве</p>
</div>,
document.getElementById('portal-root') // Любой DOM-узел
);
}
Реальный сценарий: рендеринг вне родителя
Сценарий: компонент должен рендериться в конкретном месте DOM, независимо от того, где он используется в React-дереве.
function App() {
return (
<div>
<Header />
<Sidebar>
{/* Этот компонент логически внутри Sidebar, но физически в другом месте */}
<NotificationBadge count={5} />
</Sidebar>
</div>
);
}
function NotificationBadge({ count }) {
// Рендерим в специальный контейнер в хедере, хотя используем в сайдбаре
return ReactDOM.createPortal(
<span className="badge">{count}</span>,
document.getElementById('header-badge-root')
);
}
Оживи свой сайт. Освой JavaScript!
Статичная верстка — это только скелет. Наш онлайн-курс "JavaScript с нуля до профи" даст твоим страницам мышцы и нервы. Научись создавать слайдеры, формы, интерактивные карты и получать данные с сервера.
От теории — к реальным скриптам в твоём портфолио.
Портал для глобальных UI-элементов
Порталы идеально подходят для глобальных элементов, которые должны быть доступны из любого компонента.
Глобальный контейнер:
<!-- index.html -->
<body>
<div id="root"></div>
<div id="portal-root"></div> <!-- Общий контейнер для порталов -->
</body>
Использование:
function GlobalUI() {
return ReactDOM.createPortal(
<div>
<Modal />
<Tooltip />
<Notifications />
</div>,
document.getElementById('portal-root')
);
}
function App() {
return (
<ThemeProvider>
<MainContent />
<GlobalUI /> {/* Всё рендерится в portal-root */}
</ThemeProvider>
);
}
Портал и события (важное отличие)
Важно: события в портале всплывают через React-дерево, а не через DOM-дерево!
function Parent() {
const handleClick = () => console.log('Клик на родителе');
return (
<div onClick={handleClick}>
{/* Кнопка в Portal */}
{ReactDOM.createPortal(
<button onClick={() => console.log('Клик на кнопке')}>
Кликни меня
</button>,
document.body
)}
</div>
);
}
// Порядок: "Клик на кнопке" → "Клик на родителе"
// React-дерево: Parent → button (несмотря на DOM-позицию)
Портал с контекстом
Context работает через портал!
const ThemeContext = createContext('light');
function ThemedButton() {
const theme = useContext(ThemeContext);
// Рендерим в body, но контекст сохраняется
return ReactDOM.createPortal(
<button style={{ background: theme === 'light' ? '#fff' : '#333' }}>
Кнопка с темой
</button>,
document.body
);
}
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<ThemedButton />
</ThemeContext.Provider>
);
}
// Кнопка получает тему через контекст, хотя физически в body
Портал и SSR (Server Side Rendering)
При SSR нужно проверять существование DOM-узла:
function Portal({ children }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
// На сервере не рендерим portal
return null;
}
const portalRoot = document.getElementById('portal-root');
if (!portalRoot) {
// Fallback, если контейнер отсутствует
return <div className="fallback">{children}</div>;
}
return ReactDOM.createPortal(children, portalRoot);
}
Множественные порталы (разные контейнеры)
// Контейнеры в HTML
<div id="modal-root"></div>
<div id="tooltip-root"></div>
<div id="notification-root"></div>
function App() {
return (
<>
{/* Модалки в одном контейнере */}
{ReactDOM.createPortal(<Modal />, document.getElementById('modal-root'))}
{/* Тултипы в другом */}
{ReactDOM.createPortal(<Tooltip />, document.getElementById('tooltip-root'))}
{/* Нотификации в третьем */}
{ReactDOM.createPortal(<Notifications />, document.getElementById('notification-root'))}
</>
);
}
Частые ошибки новичков
| Ошибка | Почему | Исправление |
|---|---|---|
| Не создают DOM-контейнер | Ошибка «Target container is not a DOM element» | Добавь <div id="portal-root"> в HTML |
| Пытаются использовать Portal на сервере | Ошибка, т.к. DOM отсутствует | Проверяй typeof window !== 'undefined' |
| Не учитывают всплытие событий | События ведут себя неожиданно | Помни: всплытие по React-дереву, не DOM |
| Используют Portal для всего | Излишнее усложнение | Используй только когда нужно разорвать DOM |
| Забывают про CSS-контекст | Стили не применяются | Portal сохраняет React-контекст, но не DOM-контекст |
Итог (что мы узнали)
| Что узнали | Как это работает |
|---|---|
| Порталы | Рендеринг в другую часть DOM |
ReactDOM.createPortal |
Создание портала |
| Разрыв DOM-иерархии | Компонент вне родительского DOM-узла |
| Сохранение React-дерева | Контекст, события, хуки работают |
| Глобальные элементы | Модалки, тултипы, нотификации |
| Множественные порталы | Разные контейнеры для разных задач |
Задание для закрепления (на 1–2 часа)
Создай два портал-компонента:
GlobalModal — модальное окно, которое рендерится в #modal-root:
- Открывается через хук
useModal - Поддерживает закрытие по
Escapeи клику на оверлей - Блокирует скролл страницы
ContextualTooltip — тултип, который:
- Рендерится в
#tooltip-root(отдельный контейнер) - Появляется при наведении на элемент
- Использует контекст темы (светлая/тёмная) для стилизации
Дополнительно: создай контейнер #portal-root и компонент PortalProvider, который автоматически создаёт все необходимые DOM-контейнеры при монтировании.
Проверка: Убедись, что оба компонента используют разные DOM-контейнеры и работают независимо от родительских CSS-стилей.
Что дальше?
В следующем уроке разберём Error Boundaries — механизм отлова ошибок в React-компонентах. Ты узнаешь, как не дать ошибке «уронить» всё приложение, показывать красивые fallback-интерфейсы и логировать ошибки для отладки. Это спасёт твои приложения от краша на глазах у пользователей.