Synthetic Events в React: как работает система событий

Разбираем Synthetic Events в React: делегирование, bubbling/capturing, приоритеты обновлений, интеграция с native events и production-ошибки.

06 марта 2026 г.19 минLexicon Team

Введение

Тема Synthetic Events в React регулярно недооценивается: многие знают только факт, что «React оборачивает DOM-события». На практике этого недостаточно, потому что ошибки обычно возникают в интеграции React-обработчиков с нативными listeners, порталами, сложными формами и сторонними виджетами.

Если вы работаете с интерактивными интерфейсами, понимание системы событий напрямую влияет на стабильность UX и на предсказуемость рендеров. Для общего контекста полезно параллельно изучить разбор жизненного цикла компонента, материал про batching обновлений и статью о том, когда React реально ререндерит компонент.

В этом разборе пройдем весь путь: что такое Synthetic Events, как устроено делегирование, где границы с native DOM API и какие production-паттерны реально работают в командах.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Что такое Synthetic Events и зачем они React

Короткий ответ

Synthetic Event — это объект события, который React передает в обработчики (onClick, onChange, onKeyDown и т.д.) вместо прямой работы с браузерным событием. Он нормализует API между браузерами и встроен в систему обновлений React.

Что React выигрывает от такого слоя

  • единый интерфейс событий между браузерами;
  • централизованное делегирование (меньше отдельных listeners в DOM);
  • связка события с приоритетом обновлений и планировщиком рендера;
  • предсказуемое поведение в дереве React-компонентов.

Что важно помнить

Synthetic Event не «отменяет» нативный DOM. Внутри есть ссылка на исходное событие (nativeEvent), а в сложных сценариях нужно понимать обе модели одновременно: React-цепочку и native propagation.

Архитектура: как React обрабатывает событие

Упрощенная схема такая:

  1. Браузер генерирует native event.
  2. React ловит его на уровне делегированного listener.
  3. Создается Synthetic Event с нормализованными полями.
  4. React проходит фазу capture и bubble по дереву React.
  5. Из обработчика ставятся обновления state в очередь React.
  6. Планировщик определяет, когда выполнить рендер и commit.

Ключевые компоненты архитектуры:

  • слой делегирования событий;
  • механизм извлечения и нормализации события;
  • маршрутизация обработчиков capture/bubble;
  • интеграция с приоритетами обновлений.

Точка частого отказа: разработчик предполагает, что stopPropagation в одном месте остановит вообще все обработчики в приложении. Это неверно, если часть логики живет в нативных listeners вне React-root.

Делегирование событий: что изменилось после React 17

До React 17 React вешал многие обработчики на document. Начиная с React 17, делегирование привязано к корневому контейнеру (root container). Это важно в проектах, где на одной странице живут несколько независимых React-приложений или гибрид React + legacy jQuery/vanilla.

Практический эффект:

  • меньше неожиданных пересечений между независимыми root;
  • проще изолировать поведение микрофронтендов;
  • меньше «магии» на уровне глобального document.

Но если у вас есть старый код с нативными listeners на document, порядок срабатывания может отличаться от того, что команда помнит по React 16.

Capture и Bubble в React на практике

React поддерживает обе фазы:

  • capture: onClickCapture;
  • bubble: onClick.
function EventPhasesDemo() {
  return (
    <div
      onClickCapture={() => console.log("parent capture")}
      onClick={() => console.log("parent bubble")}
      style={{ padding: 16, border: "1px solid #ccc" }}
    >
      <button
        onClickCapture={() => console.log("button capture")}
        onClick={() => console.log("button bubble")}
      >
        Нажми
      </button>
    </div>
  );
}

Такой пример нужен не ради теории: в production через capture часто решают аналитику, трекинг и предвалидацию до основной бизнес-логики bubble-фазы.

Работа с Synthetic Event объектом

Раньше часто встречался совет вызывать event.persist(), чтобы сохранить событие для асинхронного использования. В актуальном React пуллинг событий удален, поэтому этот паттерн в большинстве случаев больше не нужен.

function InputLogger() {
  const [value, setValue] = useState("");

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const nextValue = e.currentTarget.value;
    setValue(nextValue);

    setTimeout(() => {
      // В React 17+ это безопасно без persist в типичном сценарии.
      console.log("latest typed:", nextValue);
    }, 300);
  };

  return <input value={value} onChange={handleChange} placeholder="Введите текст" />;
}

На практике лучше сохранять нужные данные сразу (nextValue), а не прокидывать весь event-объект глубоко в async-цепочки.

Таблица сравнения: Synthetic Events и альтернативы

КритерийSynthetic Events (React)Native DOM eventsКастомный event bus
Кроссбраузерная унификацияДаЧастично, зависит от APIНет, зависит от реализации
Интеграция с React-рендеромПолнаяОграниченнаяОтсутствует
Делегирование по умолчаниюДаНужно организовывать вручнуюНе относится к DOM
Контроль capture/bubble в дереве ReactДаДа, но в DOM-деревеНет нативной модели DOM-фаз
Риск конфликтов с React stateНизкийСредний/высокийСредний
Типичные кейсыUI-события в компонентахИнтеграция с внешними SDK, document-level hooksДоменные события между модулями
Главные ограниченияНужно понимать границу с native listenersБольше ручной рутины и cleanupЛегко создать второй источник истины

Production pitfalls: где чаще всего ломается система событий

Ошибка 1. Смешивание React-обработчиков и native listeners без явной границы

Симптомы:

  • обработчики срабатывают дважды;
  • stopPropagation не дает ожидаемого эффекта;
  • поведение отличается между экранами с разным монтажом root.

Последствие: нестабильный UX и трудно воспроизводимые баги.

Профилактика: разграничить зоны ответственности. Либо событие обрабатывается внутри React-дерева, либо на уровне native listeners с четкой причиной (например, интеграция SDK).

Ошибка 2. Отсутствие cleanup для нативных подписок

Симптомы:

  • после переходов по роутам обработчик срабатывает многократно;
  • растет потребление памяти;
  • в логах видно дубли одинаковых действий.

Последствие: деградация производительности длинной сессии.

Профилактика: всегда удалять addEventListener в cleanup эффекта, где подписка создавалась.

Ошибка 3. Тяжелая логика прямо в onChange/onMouseMove

Симптомы:

  • ввод текста «тормозит»;
  • курсор мыши дергается на слабых устройствах;
  • profiling показывает длинные обработчики событий.

Последствие: просадка отзывчивости даже при корректном batching.

Профилактика: отделять срочную UI-часть от тяжелой постобработки (startTransition, debounce, web worker, серверная фильтрация).

Ошибка 4. Неучтенные особенности portal и modal-слоев

Симптомы:

  • клик в модалке закрывает фон не в том порядке;
  • outside-click логика срабатывает неожиданно;
  • аналитика получает лишние события.

Последствие: регрессии в критичных пользовательских сценариях.

Профилактика: проектировать события с учетом portal-структуры и явно тестировать capture/bubble последовательность.

Разбор производительности

Synthetic Events сами по себе редко являются узким местом. Чаще всего проблема заключается в том, что происходит после события:

  • каскад setState без декомпозиции дерева;
  • синхронная тяжелая фильтрация;
  • лишние ререндеры из-за нестабильных props;
  • дорогое DOM-измерение после каждого ввода/скролла.

Где система событий помогает:

  • делегирование снижает количество DOM-listeners;
  • единая модель упрощает оптимизацию и профилирование;
  • лучше прогнозируется поведение в больших деревьях.

Где не помогает:

  • если бизнес-логика в обработчике занимает десятки миллисекунд;
  • если каждый event триггерит пересчет больших структур;
  • если архитектура состояния провоцирует массовые ререндеры.

Практический ориентир:

  1. Профилируйте не только commit time, но и длительность самих обработчиков.
  2. На интенсивных событиях (input, mousemove, scroll) измеряйте 95-й перцентиль задержки.
  3. Делайте оптимизацию только после метрик на реальных устройствах.

Прокачай React за 7 дней

20 вопросов и разборов по React Hooks.

Начать

Когда Synthetic Events не закрывают задачу

Есть сценарии, где нужен прямой native API:

  1. Глобальные события вне React-root (например, legacy-виджет или интеграция CMS-скрипта).
  2. Специфичные браузерные события, которые удобнее обрабатывать на уровне window/document.
  3. Интеграция со сторонними библиотеками, которые уже управляют собственным event lifecycle.

В таких случаях лучше явно признать гибридную архитектуру, чем делать вид, что «все через React». Главное правило: один сценарий, один источник правды о событии.

Практики, которые работают в командах

  • В ревью отмечайте слой обработки события: React-only или native-interop.
  • Для каждого native listener фиксируйте причину и cleanup-стратегию в коде.
  • Выносите сложные обработчики в отдельные функции/хуки, чтобы уменьшить шум JSX.
  • Добавляйте автоматические тесты на порядок capture/bubble для критичных флоу.
  • В observability собирайте метрики по частоте и длительности интерактивных обработчиков.
  • Для рискованных изменений в event-логике делайте rollout через feature flag и заранее готовьте rollback-план.

Частые ошибки

  • Полагаться на устаревшие советы про обязательный event.persist().
  • Считать, что stopPropagation всегда блокирует любые нативные обработчики вокруг React.
  • Держать тяжелую бизнес-логику прямо в onChange.
  • Не учитывать, что модалки и dropdown часто рендерятся через portal.
  • Смешивать два канала событий без документированной архитектуры.

Как отвечать на интервью про Synthetic Events

Рабочая структура ответа:

  1. Synthetic Event — это нормализованная обертка React над native DOM event.
  2. React использует делегирование и прогоняет capture/bubble по своему дереву обработчиков.
  3. После React 17 делегирование привязано к root container, а не к document.
  4. event.persist() обычно больше не нужен, потому что пуллинг удален.
  5. В production важно понимать границу между React-обработчиками и native listeners.

Такой ответ показывает, что вы знаете не только API, но и эксплуатационные риски.

Прокачай React для собеседований и production-задач

Разберем события, рендеринг и оптимизацию на реальных кейсах с персональной обратной связью.

Начать подготовку

Практический кейс из production: outside click для dropdown

Кейс: нужно закрывать dropdown по клику вне компонента. Команда часто делает только onBlur и получает баги с вложенными элементами, portal-рендером и мобильными устройствами.

Надежный вариант при гибридном подходе (React + native):

function UserMenu() {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const handlePointerDown = (e: PointerEvent) => {
      const target = e.target as Node | null;
      if (!rootRef.current || !target) return;
      if (!rootRef.current.contains(target)) {
        setOpen(false);
      }
    };

    document.addEventListener("pointerdown", handlePointerDown);
    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, []);

  return (
    <div ref={rootRef}>
      <button onClick={() => setOpen((v) => !v)}>Меню</button>
      {open && (
        <ul>
          <li>Профиль</li>
          <li>Настройки</li>
          <li>Выход</li>
        </ul>
      )}
    </div>
  );
}

Почему это production-safe:

  • используется pointerdown, чтобы закрытие происходило раньше цепочки click;
  • есть явный cleanup подписки;
  • React-state остается единственным источником истины для UI.

Граница подхода: если dropdown рендерится в portal вне rootRef, проверку contains нужно проектировать с учетом container portal.

Чек-лист внедрения event-логики в существующий код

  1. Где живет обработчик: React или native? Без этого шага команда почти всегда получает конфликт propagation.

  2. Есть ли cleanup у подписок? Если нет cleanup, утечки и дубли обработчиков неизбежны.

  3. Тяжелый ли обработчик? Если да, вынесите дорогую часть в отложенную/несрочную ветку.

  4. Проверена ли работа с порталами и вложенными-компонентами? События в overlay-слоях ломаются в первую очередь.

  5. Есть ли тесты на порядок срабатывания? Критичные сценарии должны покрывать capture/bubble и outside-click.

Этот чек-лист экономит много времени на регрессиях после рефакторинга интерактивных экранов.

FAQ

Synthetic Event полностью заменяет native event?

Нет. Он дает унифицированный интерфейс в React, но доступ к nativeEvent остается, и в интеграциях с внешним DOM-кодом нативный слой важен.

Нужно ли всегда писать onClickCapture?

Нет. Capture-фаза нужна точечно, когда важен приоритет обработки до bubble-логики.

Почему stopPropagation не всегда останавливает «все»?

Потому что часть обработчиков может находиться в отдельной нативной цепочке вне React, и на них React stopPropagation не распространяется автоматически.

Есть ли смысл использовать native listeners внутри React-приложения?

Да, но только по четкой причине: глобальные события, SDK-интеграции или специальные сценарии платформы.

Что важнее для производительности: Synthetic Events или оптимизация рендеров?

Обычно оптимизация рендеров и обработчиков важнее. Synthetic Events дают инфраструктурную базу, но не исправляют тяжелую бизнес-логику сами по себе.

Итоги

Synthetic Events в React решают практическую задачу: дают единый слой обработки событий и связывают интерактивность с системой рендера. Это снижает количество ошибок, если команда понимает границу между React и native DOM.

В production выигрывают команды, которые проектируют события как часть архитектуры: фиксируют слой ответственности, удаляют подписки, измеряют стоимость обработчиков и отдельно тестируют capture/bubble в критичных сценариях.

Если хотите углубиться дальше, полезно пройти разбор React Hooks для интервью, гид по memo/useMemo/useCallback и материал по error boundaries.

Больше вопросов в Telegram

Ежедневные разборы и реальные кейсы с интервью.

Подписаться

Автор

Lexicon Team

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