Что на самом деле происходит, когда ты пишешь onClick
- React
- События
- Portal
- Архитектура
Когда начинаешь работать с React, события кажутся простой абстракцией над нативным DOM — просто пишешь onClick вместо addEventListener и всё работает. Но за этим простым API скрывается целая система, понимание которой объясняет многие неочевидные баги и позволяет писать более предсказуемый код.
Event Delegation: один обработчик вместо тысячи
Первое и главное, что нужно понять: когда ты пишешь onClick на JSX-элементе, React не вешает addEventListener на этот элемент. Вообще.
// Кажется, что каждая кнопка получает свой обработчик
{
items.map((item) => (
<button key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</button>
))
}
На самом деле React регистрирует один обработчик на корневом элементе приложения (#root). Когда пользователь кликает по кнопке, событие всплывает по DOM-дереву до #root, где React перехватывает его и сам определяет, какой компонент должен его обработать.
Это называется event delegation (делегирование событий) — классический паттерн оптимизации DOM, который React использует под капотом.
Из этого следует практический вывод: внутри React-компонентов по возможности не стоит вручную вызывать addEventListener на DOM-элементах. В большинстве случаев достаточно onClick / onChange / onSubmit и других JSX-пропов: React сам правильно делегирует события и управляет подписками/отписками.
Почему это важно для производительности
// ❌ Нативный подход: 10 000 обработчиков в памяти
items.forEach((item) => {
document
.getElementById(item.id)
.addEventListener('click', () => handleClick(item.id))
})
// ✅ React: всегда один обработчик, независимо от размера списка
items.map((item) => (
<div key={item.id} onClick={() => handleClick(item.id)}>
{item.name}
</div>
))
При рендере 10 000 элементов нативный подход создаёт 10 000 обработчиков в памяти. React создаёт один. Это особенно критично для длинных виртуализированных списков.
Как избавиться от addEventListener в компоненте
Рассмотрим упрощённый пример: компонент вручную навешивает обработчик клика через addEventListener.
// ❌ Антипаттерн: ручной addEventListener на DOM-узел
function SaveButton() {
useEffect(() => {
const button = document.getElementById('save-button')
if (!button) return
function handleClick() {
console.log('Сохраняем…')
}
button.addEventListener('click', handleClick)
return () => button.removeEventListener('click', handleClick)
}, [])
return (
<button id="save-button" type="button">
Сохранить
</button>
)
}
Тот же компонент можно переписать, полностью опираясь на систему событий React:
// ✅ Нормальный React-подход: JSX-проп onClick
function SaveButton() {
function handleClick() {
console.log('Сохраняем…')
}
return (
<button type="button" onClick={handleClick}>
Сохранить
</button>
)
}
Здесь тебе не нужно думать о том, когда добавлять или снимать обработчик — React сам управляет жизненным циклом. Ручной addEventListener обычно оправдан только для:
- интеграции с чужими виджетами/библиотеками, которые сами работают с DOM
- глобальных событий наподобие
resize,scroll,visibilitychange— и то их лучше выносить в отдельные хуки (useWindowWidth,usePageVisibilityи т.п.), а не размазывать по компонентам.
React 16 vs React 17+: куда переехал обработчик
До React 17 единственный обработчик висел на document. Начиная с React 17 — на корневом контейнере (#root).
React 16:
window
└── document ← 🎯 здесь висел обработчик React
└── body
└── #root
└── <App />
React 17+:
window
└── document
└── body
└── #root ← 🎯 теперь здесь
└── <App />
Это изменение выглядит незначительным, но оно решило серьёзные проблемы.
Проблема с микрофронтендами
Когда на одной странице работают две независимые React-приложения (например, в микрофронтендах), оба в React 16 вешали обработчики на document. Это приводило к конфликтам: stopPropagation() в одном приложении мог случайно блокировать события другого.
// Приложение A (React 16) на document
// Приложение B (React 16) на document
// Их обработчики конкурируют друг с другом
// React 17+: каждое приложение изолировано в своём #root
ReactDOM.createRoot(document.getElementById('app-a')).render(<AppA />)
ReactDOM.createRoot(document.getElementById('app-b')).render(<AppB />)
Проблема с jQuery и нативным JS
// React 16 — этот обработчик срабатывал ПОСЛЕ React,
// потому что оба жили на document
document.addEventListener('click', (e) => {
// В React 16 к этому моменту React уже обработал событие
// и SyntheticEvent был "очищен"
console.log(e.target) // могло быть undefined
})
SyntheticEvent: обёртка над нативным событием
React не передаёт тебе нативный DOM-event. Вместо этого он создаёт SyntheticEvent — кросс-браузерную обёртку с тем же интерфейсом.
function handleClick(e) {
console.log(e instanceof MouseEvent) // false
console.log(e.nativeEvent instanceof MouseEvent) // true ← нативное событие здесь
// Но API такой же:
e.preventDefault()
e.stopPropagation()
e.target
e.currentTarget
}
Зачем нужна обёртка?
Исторически разные браузеры реализовывали события по-разному. SyntheticEvent нормализует эти различия, давая единый предсказуемый API. Сегодня, когда IE мёртв, это менее актуально, но React сохраняет абстракцию по другим причинам — в частности, для батчинга обновлений состояния внутри обработчиков.
Event Pooling (только React ≤ 16)
В React 16 и раньше SyntheticEvent переиспользовался — после того как обработчик завершался, все свойства события обнулялись:
// React 16: БАग
function handleClick(e) {
setTimeout(() => {
console.log(e.target) // null! Событие уже "очищено"
}, 100)
}
// React 16: исправление через e.persist()
function handleClick(e) {
e.persist() // "вытащить" событие из пула
setTimeout(() => {
console.log(e.target) // теперь работает
}, 100)
}
В React 17+ event pooling убрали. Событие теперь живёт столько, сколько нужно. e.persist() стал no-op и больше не нужен.
Как всплывает SyntheticEvent: Portal и неочевидное поведение
Порталы добавляют к всплытию событий ещё один слой абстракции. Интуитивно кажется: «если модалка отрендерена в document.body, то её клики не должны всплывать в родительский div где‑то в глубине дерева». В нативном DOM это так и работает. Но у React другое правило: всплытие синтетических событий идёт по дереву React-компонентов, а не по физическому DOM.
Чтобы это проще представить, полезно держать в голове сразу два дерева:

Слева — логическое дерево компонентов React, справа — реальное DOM-дерево после createPortal.
Простой пример с модалкой
React Portal позволяет рендерить компонент в другой DOM-узел — например, модальное окно в document.body, хотя компонент находится глубоко в дереве.
function Modal({ children }) {
return ReactDOM.createPortal(
<div className="modal">{children}</div>,
document.body, // рендерим в body, а не в родительский div
)
}
function App() {
return (
<div onClick={() => console.log('App clicked!')}>
<Modal>
<button onClick={() => console.log('Button clicked!')}>
Нажми меня
</button>
</Modal>
</div>
)
}
При клике на кнопку в консоли появится:
Button clicked!
App clicked!
Это нормальное поведение для React: он перехватывает нативный клик на корне (#root), а дальше поднимается по своему логическому дереву компонентов и последовательно вызывает обработчики — сначала button.onClick, потом div.onClick в App.
Ещё раз это же, но визуально:

Справа — нативный DOM: кнопка в document.body. Слева — дерево React: App → Modal → button. Всплытие SyntheticEvent идёт по левому дереву.
Практическое применение
Это поведение на самом деле удобно — оно позволяет модальным окнам нормально работать с контекстом и обработчиками родителя:
function Dropdown({ onSelect }) {
// Кликаем на опцию внутри Portal —
// событие всплывёт и закроет дропдаун
return ReactDOM.createPortal(
<ul>
{options.map((opt) => (
<li key={opt.id} onClick={() => onSelect(opt)}>
{opt.label}
</li>
))}
</ul>,
document.body,
)
}
Компонент Dropdown может жить где угодно в дереве, но клики по пунктам меню будут всплывать до обработчиков родителя так, как будто Portal и не существует.
Иногда это поведение неожиданно. Например, если ты пытаешься закрыть модалку кликом “вне неё”, используя stopPropagation:
// ❌ Не работает как ожидается
function Modal({ onClose, children }) {
return ReactDOM.createPortal(
<div className="overlay" onClick={onClose}>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()} // хотим остановить — но остановит ли?
>
{children}
</div>
</div>,
document.body,
)
}
stopPropagation здесь работает корректно внутри React-дерева портала. Но нужно понимать: если снаружи модалки есть нативные обработчики, они могут вести себя неожиданно.
stopPropagation и нативные обработчики
Это один из самых коварных источников багов при интеграции React с нативным JS или сторонними библиотеками.
// Нативный обработчик, повешенный снаружи React
document.addEventListener('click', () => {
console.log('document click') // Этот обработчик...
})
function Component() {
return (
<div
onClick={(e) => {
e.stopPropagation()
console.log('React click')
}}
>
Кликни меня
</div>
)
}
При клике в консоли появится:
React click
document click ← всё равно срабатывает!
Почему stopPropagation не помогает
Вспомним схему: React перехватывает события на уровне #root. Когда ты вызываешь e.stopPropagation() внутри React-обработчика, ты останавливаешь всплытие только внутри синтетической системы React. Но нативное DOM-событие к этому моменту уже добралось до #root — и продолжает всплывать дальше в нативном DOM до document.
Нативное событие:
button → div → ... → #root (React обрабатывает) → document ← нативный обработчик здесь
↑
stopPropagation останавливает только синтетическое всплытие
Нативное продолжает идти вверх
Как правильно остановить
Если нужно действительно остановить нативное всплытие — используй nativeEvent:
function Component() {
return (
<div
onClick={(e) => {
e.nativeEvent.stopImmediatePropagation() // останавливает нативные обработчики
}}
>
Кликни меня
</div>
)
}
Но это грубый инструмент. Лучше пересмотреть архитектуру: если ты смешиваешь React-обработчики и нативный JS на document, это сигнал, что что-то пошло не так.
Capture vs Bubble фазы
По умолчанию React-обработчики работают в фазе всплытия (bubble). Для перехвата в фазе захвата (capture) используется суффикс Capture:
function App() {
return (
<div
onClickCapture={() => console.log('div capture')}
onClick={() => console.log('div bubble')}
>
<button
onClickCapture={() => console.log('button capture')}
onClick={() => console.log('button bubble')}
>
Кликни
</button>
</div>
)
}
// Порядок вывода:
// div capture ← фаза захвата, сверху вниз
// button capture
// button bubble ← фаза всплытия, снизу вверх
// div bubble
Capture-фаза полезна, когда нужно перехватить событие до того, как его обработают дочерние компоненты — например, для глобальных горячих клавиш или логирования.
Итог
Система событий React — это не просто синтаксический сахар. Под капотом работает полноценная инфраструктура:
- Event delegation: один обработчик на
#rootвместо тысячи на каждом элементе - SyntheticEvent: кросс-браузерная обёртка, которая с React 17 больше не имеет пуллинга
- Portal: всплытие идёт по React-дереву, а не DOM-дереву — это фича, а не баг
- stopPropagation: останавливает только синтетическое всплытие, нативные обработчики на document всё равно сработают
- React 17+: обработчик переехал с
documentна#root, что решило проблемы с микрофронтендами и изоляцией
Понимание этих деталей объясняет класс багов, которые иначе выглядят как “магия”: почему закрытие модалки через клик снаружи не работает, почему jQuery-обработчики конфликтуют с React, почему Portal всплывает “не туда”.