Что на самом деле происходит, когда ты пишешь onClick

8 мин чтения
  • 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-дерево с Portal

Слева — логическое дерево компонентов 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.

Ещё раз это же, но визуально:

Всплытие: кликаем по кнопке в Portal, событие проходит через обработчик 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 всплывает “не туда”.