React Portals
React Portals позволяют рендерить компоненты за пределами DOM-иерархии родителя. Модальные окна, тосты, тултипы без проблем с z-index и overflow.
Что такое Portal
Portal рендерит дочерний элемент в DOM-узел, который находится за пределами родительского компонента. При этом React-дерево остаётся прежним — события всплывают как обычно.
import { createPortal } from 'react-dom'
function Modal({ children }: { children: React.ReactNode }) {
return createPortal(
<div className="modal-overlay">
<div className="modal-content">
{children}
</div>
</div>,
document.body,
)
}
Зачем нужны Portals
Проблемы без Portals:
function Card() {
return (
<div className="overflow-hidden"> {/* overflow: hidden */}
<div className="relative z-10">
<Tooltip>Подсказка</Tooltip> {/* Обрезается или прячется */}
</div>
</div>
)
}
Родительский overflow: hidden, z-index или transform могут сломать выпадающие списки, модалки и тултипы. Portal решает это — контент рендерится прямо в body.
Модальное окно
import { useEffect, createPortal } from 'react-dom'
interface ModalProps {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}
function Modal({ isOpen, onClose, children }: ModalProps) {
useEffect(() => {
if (!isOpen) return
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) return null
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="bg-white rounded-lg p-6 max-w-md w-full"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body,
)
}
Использование:
function App() {
const [isOpen, setIsOpen] = useState(false)
return (
<div className="overflow-hidden">
<button onClick={() => setIsOpen(true)}>Открыть</button>
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<h2>Заголовок</h2>
<p>Содержимое модального окна</p>
<button onClick={() => setIsOpen(false)}>Закрыть</button>
</Modal>
</div>
)
}
Toast-уведомления
function ToastContainer({ toasts }: { toasts: Toast[] }) {
return createPortal(
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
{toast.message}
</div>
))}
</div>,
document.body,
)
}
Выпадающий список с Portal
function Dropdown({ trigger, items }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLButtonElement>(null)
function updatePosition() {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
setPosition({
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX,
})
}
return (
<>
<button
ref={triggerRef}
onClick={() => {
updatePosition()
setIsOpen(!isOpen)
}}
>
{trigger}
</button>
{isOpen && createPortal(
<ul
className="absolute bg-white border rounded shadow-lg"
style={{ top: position.top, left: position.left }}
>
{items.map((item) => (
<li key={item.value} onClick={() => { item.onClick(); setIsOpen(false) }}>
{item.label}
</li>
))}
</ul>,
document.body,
)}
</>
)
}
События работают как обычно
Несмотря на то, что Portal рендерит в другой DOM-узел, события всплывают по React-дереву:
function Parent() {
return (
<div onClick={() => console.log('Родитель поймал клик!')}>
<Modal>
<button>Кликни</button>
{/* Клик по кнопке вызовет обработчик Parent */}
</Modal>
</div>
)
}
Это значит, что stopPropagation() на оверлее модалки не сломает обработчики навигации, которые находятся выше в React-дереве.
SSR и Portals
createPortal обращается к document.body, которого нет на сервере. Для SSR нужен_guard_:
function Modal({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return createPortal(<div>{children}</div>, document.body)
}
Или используйте 'use client' в Next.js — тогда компонент рендерится только на клиенте.
Итог
Portals решают три проблемы: overflow: hidden, z-index конфликты и позиционирование относительно вьюпорта. Используйте их для модалок, тостов, тултипов и выпадающих списков. События продолжают всплывать по React-дереву — это удобно и предсказуемо.