Всплытие, отмена и остановка в React

события≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В части II всплытие вы прошли руками: клик по кнопке добирался до контейнера, делегирование ловило его через closest, а чужой stopPropagation() глушил аналитику на document. В React обработчик пишется прямо на элементе, и кажется, что всё это осталось позади. Не осталось: событие так же идёт по дереву вверх, а e.stopPropagation() и e.preventDefault() делают ровно то же самое. Изменилось одно — где висит настоящий слушатель, и без этого знания код части II и React начинают глушить друг друга.

Разделы темы:·

Кнопка внутри карточки, карточка внутри списка, на всех трёх — onClick. В части II с addEventListener вы бы ответили не задумываясь: сначала цель, потом её предки по очереди. Но onClick в JSX — не addEventListener, так что запишите прогноз: сколько строк напечатает клик по кнопке и в каком порядке.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три строки, от цели вверх — как в части II. React не придумывал свою модель событий: он по-своему подписывается на браузерную (об этом дальше), а путь у события прежний. Поля тоже прежние: target — где клик случился, и он не меняется на всём пути; currentTarget — чей обработчик работает сейчас.

Компоненты для всплытия прозрачны. Если кнопка живёт в компоненте TaskButton, а <ul onClick> — в родительском Board, клик всё равно дойдёт до списка: React смотрит на дерево элементов, а не на границы функций. Родителю не нужно ничего знать о содержимом ребёнка, чтобы получать его клики.

Обработчик получает не браузерный объект события, а обёртку React — синтетическое событие. Поля и методы у неё те же, а браузерный оригинал лежит в e.nativeEvent.

Отличие, которое подсунет компилятор: в типах React currentTarget — точно тот элемент, на котором стоит обработчик, а target — всего лишь EventTarget.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Свойство "id" не существует в типе "EventTarget"» — компилятор прав: кликнуть могли по кнопке, по тексту, по картинке внутри, и тип не в силах угадать, по чему именно. Это тот же target из части II, только честно типизированный. Сужайте его, как учила часть III: if (e.target instanceof HTMLElement) — и внутри ветки есть и id, и closest. А если нужен элемент с обработчиком, берите currentTarget: он уже типизирован.

Самая частая причина остановить всплытие — вложенные интерактивные элементы. Карточка задачи открывается по клику, а внутри неё кнопка «удалить». Без остановки клик по кнопке удалит задачу и тут же откроет её карточку: оба обработчика стоят на пути события, оба сработают.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна строка вместо двух. e.stopPropagation() у синтетического события делает ровно то, чего вы ждёте от него после части II: обрывает путь вверх, и onClick карточки не вызывается. Предупреждение оттуда тоже в силе: остановка глушит всех, кто выше, — не только карточку, но и document, где могла жить аналитика или «закрыть по клику снаружи». Почему так — в разделе про корень.

Когда оба обработчика ваши и стоят в одном файле, остановка — нормальное решение, документация React сама её советует. Спорной она становится, если событие ждут где-то ещё. Тогда работает рецепт части II — решать вопрос там, где он мешает: родитель сам проверяет, откуда пришёл клик.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

stopImmediatePropagation у синтетического события нет — и не нужен: на элементе один onClick, глушить соседей некого. Почему обработчик на элементе один, разбирала предыдущая статья главы.

Отмена действия браузера — отдельный вопрос, и React его не переосмыслял. Но здесь есть ловушка для тех, кто помнит старый атрибут onclick или jQuery: там переход отменял return false. Напишите так в React — и предскажите, куда денется страница после клика.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Переход состоялся, а компилятор промолчал. React не читает, что вернул обработчик, и типы здесь не помощник: обработчик обязан возвращать void, а функцию с boolean на место void ставить можно — это статья «void: почему колбэк может вернуть что угодно» из части III. Единственный способ отменить действие — тот же, что в части II.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Флаг defaultPrevented тоже есть у синтетического события, и его видят обработчики выше по дереву. Это тот самый «флаг на объекте события» из статьи про stopPropagation: ребёнок отменил действие, родитель об этом узнал — и никто никого не глушил.

Что нужноЧто писатьЧего это не делает
Не дать браузеру перейти по ссылке, отправить форму, поставить галочкуe.preventDefault()Не мешает обработчикам родителей: событие идёт дальше
Не дать сработать onClick родителейe.stopPropagation()Не отменяет действие браузера; заодно глушит слушатели выше корня React
Узнать, отменил ли кто-то действие нижеe.defaultPreventedНичего не меняет — это только флаг
Отменить переход по-старомуreturn falseНичего: React возврат обработчика не читает

Самый частый preventDefault в React стоит в onSubmit: иначе браузер отправит форму и перезагрузит страницу вместе с состоянием всех компонентов. Что делать с полями дальше — тема двух следующих статей.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В проекте части II вы повесили один обработчик на контейнер доски (статья «Рендер из состояния и делегирование») и разбирали клики через closest — подписывать каждую карточку заново после каждой перерисовки было невыносимо. React решает ту же проблему тем же способом, только оптом: в момент createRoot он вешает на контейнер по браузерному слушателю на каждое событие, которое умеет. Это уже проверено переносом кнопки из-под корня в статье «Обработчики в JSX против addEventListener»; здесь важно другое — где слушатель корня стоит в очереди. onClick в JSX — не подписка на кнопке, а запись в пропсах, которую React находит, когда клик всплыл до корня.

Проверить это можно старыми средствами. Повесьте на кнопку и на document обычные addEventListener и предскажите, где в этой очереди окажется onClick.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

onClick сработал после слушателя на кнопке и до слушателя на document — ровно там, где стоит #root. Путь клика по странице с React выглядит так:

Где событиеЧто срабатывает
кнопкаеё собственные addEventListener
предки внутри #rootих собственные addEventListener
#rootвсе onClick из JSX, от цели к корню
document, windowслушатели страницы, аналитика, «закрыть по клику снаружи»

Делегирование через closest в React обычно не нужно: onClick на каждом <li> — это не сотня подписок, а сотня функций в пропсах. Настоящий слушатель по-прежнему один.

Старые статьи и ответы на форумах уверяют, что React подписывается на document. Так было до 17-й версии, и это мешало: два React-приложения на одной странице делили одни слушатели, а stopPropagation() в обычном коде между кнопкой и document глушил React целиком. С React 17 слушатели живут на контейнере корня, и несколько корней не мешают ни друг другу, ни странице вокруг.

Раз настоящий слушатель на корне, любая остановка события между кнопкой и корнем лишит React события целиком. Так ведёт себя чужой виджет внутри вашей разметки или старый код части II, который вы ещё не переписали. Предскажите, что покажет счётчик.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ноль. Слушатель на кнопке отработал первым и оборвал путь — до #root событие не дошло, и с точки зрения React клика не было. Ошибок в консоли нет, состояние не меняется: искать такое приходится по обработчикам, которые не вызвались вовсе.

В обратную сторону граница работает так же. e.stopPropagation() в обработчике React делает две вещи: помечает синтетическое событие остановленным, чтобы React не звал обработчики выше, и вызывает stopPropagation() у браузерного оригинала. Оригинал в этот момент уже на корне, так что дальше — на document и window — он не пойдёт.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Слушатель на document в фазе всплытия клика не увидел, а в фазе погружения — увидел: погружение проходит через document раньше, чем событие доберётся до кнопки и до корня React. Это тот же рецепт, которым часть II спасала аналитику от чужого stopPropagation(), — против React он работает так же.

У React есть и своя фаза погружения: к имени любого обработчика добавляется суффикс Capture — onClickCapture, onKeyDownCapture. Это аналог третьего аргумента addEventListener из части II: такой обработчик срабатывает по пути вниз, от корня к цели, раньше всех обычных.

React вызывает их из своего слушателя на корне в фазе погружения — то есть до того, как событие вообще доберётся до кнопки. Отсюда следствие, которое стоит предсказать: увидит ли onClickCapture клик, если чужой код на кнопке его глушит?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Одна строка из трёх. Обработчик погружения отработал до того, как чужой код получил событие; обработчики всплытия ждали клик на корне и не дождались. Поэтому onClickCapture на самом верхнем элементе приложения — подходящее место для аналитики и всего, что обязано видеть каждый клик, что бы ни делали с событием ниже.

Полный порядок одного клика: погружение через document → onXxxCapture от корня к цели → браузерные слушатели на кнопке и её предках → onXxx от цели к корню → всплытие через document и window.

  • Событие в React проходит тот же путь, что в браузере: от цели вверх по предкам, сквозь границы компонентов; target и currentTarget значат то же, что в части II.
  • e.stopPropagation() обрывает путь к родительским onClick и заодно глушит слушатели выше корня; e.preventDefault() отменяет действие браузера и никому не мешает; return false не делает ничего.
  • Настоящие слушатели React висят на контейнере корня (с 17-й версии, раньше — на document): onClick из JSX срабатывает после addEventListener на самой кнопке и до слушателей на document.
  • Чужой stopPropagation между кнопкой и корнем лишает React события целиком — без ошибок и без перерисовки.
  • Обработчики с суффиксом Capture работают в фазе погружения, до кнопки и до чужого кода; слушатель на document с capture тоже видит клик, остановленный внутри React.
  • В типах React currentTarget — точный элемент с обработчиком, а target — только EventTarget: сужайте его через instanceof.

1/4Кнопка лежит в <li>, тот — в <ul>; на всех трёх стоит onClick с console.log. В каком порядке они напечатают строки при клике по кнопке?

2/4На кнопке с React-onClick вдобавок висит обычный addEventListener("click"), ещё один — на document. Что сработает первым, вторым и третьим?

3/4Ссылка <a href="/tasks"> получила onClick={() => false}. Что скажет компилятор и что сделает браузер?

4/4Чужой код повесил на кнопку addEventListener("click", e => e.stopPropagation()). На родительском <div> стоят и onClick, и onClickCapture. Какие из них сработают?

Удалить, не открывая

Клик по карточке печатает «открыть», кнопка внутри — «удалить». Сейчас клик по кнопке печатает обе строки. Сделайте так, чтобы печаталась только «удалить», причём двумя способами: с остановкой всплытия и без неё.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый способ — оборвать путь события в обработчике кнопки:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Коротко, но вместе с карточкой клика лишатся и слушатели выше корня — например, аналитика на document. Второй способ — не глушить, а разбираться в карточке, откуда пришёл клик:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

closest здесь надёжнее сравнения с HTMLButtonElement: если внутри кнопки появится иконка, целью клика станет она, а не кнопка. Сужение через instanceof Element нужно компилятору — у EventTarget метода closest нет.

Ссылка внутри приложения

Ссылка ведёт на #tasks-7. Обычный клик приложение обрабатывает само: печатает «переход: #tasks-7» и не даёт браузеру никуда идти. Клик с Ctrl или Cmd — «открыть в новой вкладке» — нужно оставить браузеру, как учил раздел «Отменять вежливо» статьи про preventDefault. Допишите обработчик.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сначала проверка модификаторов — и выход без отмены, чтобы браузер сделал своё. Только потом preventDefault() и своя работа:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

ctrlKey и metaKey лежат прямо на синтетическом событии, как и на браузерном. Адрес удобно брать из e.currentTarget.hash: тип HTMLAnchorElement в параметре обработчика даёт доступ к полям ссылки без всяких сужений.

Аналитика, которую не заглушить

Счётчик кликов на document должен учитывать каждый клик по странице. Компоненты трогать нельзя, а в них щедро расставлен e.stopPropagation(). Сейчас счётчик видит ноль. Исправьте одну строку вне компонентов.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Остановка в React глушит и браузерное событие, но только выше корня и только на пути вверх. Вниз событие проходит через document раньше кнопки, так что слушатель в фазе погружения увидит каждый клик:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Это ровно тот приём, которым часть II защищала аналитику от чужого stopPropagation(). Внутри приложения то же самое даёт onClickCapture на корневом элементе.