Обработчики в JSX против addEventListener

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

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

В части II обработчик жил отдельно от разметки: render() собирал строку, а addEventListener вешался на контейнер один раз и через closest угадывал, по чему кликнули. В React обработчик записывается прямо в разметке — onClick={handleClick}, — и это не возврат к onclick="..." из старых учебников, а то же делегирование, только спрятанное внутрь библиотеки. Разберём, что происходит между кликом и вызовом вашей функции и какие две ошибки компилятор ловит ещё до запуска.

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

В части II обработчик и разметка жили порознь. render() собирал строку и клал её в innerHTML, а слушатель висел на контейнере и через closest выяснял, по чему кликнули, — потому что всё, что было привязано к самим узлам, полная пересборка стирала. JSX эту разлуку отменяет: разметка здесь не строка, а вызов функции (статья «JSX — это не HTML»), и в атрибут можно положить функцию так же, как число или объект.

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

Два клика — две строки, и в каждой свой count: обработчик создаётся заново на каждой отрисовке и замыкает её снимок, как разобрано в главе про состояние. Новое здесь только одно — onClick={handleClick}: фигурные скобки, внутри имя функции без вызова. React запоминает функцию и позовёт её, когда придёт клик.

Имя пропса пишется в camelCase, и это не стилевая прихоть. Попробуйте написать так, как привыкли в HTML, — и предскажите, что на это скажут компилятор и сам React.

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

Компилятор не только отверг onclick, но и подсказал правильное имя: оно есть в типах <button>, а строчного там нет. Строку в выводе напечатал не он, а сам React — в песочнице стоит его отладочная сборка, и она ловит ту же опечатку теми же словами. Сам проп при этом пропал без следа: в разметке остаётся <button>Сохранить</button> — ни атрибута onclick, ни свойства, — а в боевой сборке React об этом молчит, и без компилятора опечатка просто тихо не работала бы. И заметьте разницу с HTML: там в кавычках лежал текст кода, здесь значением служит сама функция.

onClick, onChange, onSubmit, onKeyDown — имя события с заглавной буквы после on. Полный список живёт в типах React: редактор подскажет его, стоит набрать on.

Самая частая ошибка первой недели с React — скобки после имени обработчика. Перед запуском ответьте на два вопроса: когда напечатается «сохраняем» и что сделает клик.

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

«Сохраняем» напечаталось до всякого клика, а сам клик прошёл впустую. save() — вызов: он выполняется в момент сборки разметки, то есть прямо в отрисовке, а в onClick уезжает его результат — undefined. Компилятор говорит то же самое своими словами: void не годится там, где ждут MouseEventHandler. React получает undefined, и звать по клику ему некого.

Когда обработчику нужен аргумент, его заворачивают в стрелку: она создаётся при отрисовке, а вызывается по клику — обычный колбэк из части I. Ниже так передаётся обработчик в свой компонент: у пропса onRemove тип функции, как у любого колбэка из части III.

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

Строки «Чай» нет, две другие на месте. Каждая Row получила свою стрелку с замкнутым title. В части II за это отвечали data-id и closest("li"), и значение приезжало строкой — приходилось звать Number(). Замыкание отдаёт значение как есть, любого типа, и искать строку по DOM не нужно.

Если в скобках оказался не безобидный save, а setCount, вызов не просто бесполезен: setCount во время отрисовки требует новую отрисовку, та снова вызывает setCount, и так по кругу. React считает повторы и после двадцати пяти останавливает их ошибкой «Too many re-renders» — эту лестницу вы уже видели в статье «Триггер, рендер, коммит». Самого текста в выводе не окажется: браузер скрывает сообщения ошибок, прилетевших из подключённого со стороны файла, и на их месте стоит заглушка — разбор строка за строкой как раз там. Серия отрисовок при этом напечатается дважды: после сбоя React один раз пробует отрисовать заново, вдруг он был случайным. Диагностика компилятора здесь та же — void вместо обработчика, — и это тот случай, когда её стоит прочитать до запуска.

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

Раз обработчик записан в разметке, кажется, что React вешает его на кнопку — как addEventListener в части II. Проверим по DOM: есть ли у кнопки обработчик и кто ещё увидит клик.

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

Первый клик увидели двое: сначала React, потом нативный слушатель на корне — он подписался позже, а слушатель React повешен на тот же корень ещё в createRoot. Второй клик не увидел никто, хотя с кнопки ничего не снимали: она просто перестала быть внутри корня, и всплывающее событие больше через него не проходит. Так ведёт себя только делегирование — обработчик на самой кнопке пережил бы перенос. Это буквально ваш board.addEventListener("click", ...) из проекта части II: один слушатель на контейнере, а не подписка на каждую кнопку.

Если заглянуть в инспекторе, у кнопки с onClick найдётся свойство onclick — и в нём пустая функция. Это костыль для Safari на iOS: без onclick он не считает элемент кликабельным и не шлёт событие. Вашего обработчика в этой функции нет, и на перенос кнопки она не влияет, что пример и показал.

Разница в том, что делается дальше. Вы шли от event.target вверх через closest и читали dataset, чтобы понять, чья это кнопка. React идёт от того же target вверх по своему дереву элементов и вызывает onClick тех, у кого он записан. Таблица «кто за что отвечает» лежит не в DOM, а в описании разметки — поэтому перерисовка обработчики не теряет, а у нового элемента они есть с первой секунды.

Делегирование из части IIonClick в React
Где висит слушательна контейнере, вешаете самина корне из createRoot, один на все события
Как находится обработчикtarget.closest(...) и проверка вручнуюот target вверх по дереву элементов React
Что при перерисовкеузлы новые, слушатель на контейнере уцелелобработчики — часть описания, обновляются вместе с разметкой
Откуда данные строкиdataset, всегда строкамииз замыкания обработчика, любым типом
Новый элементработает, если подходит под closestработает: у него свой onClick в описании

До React 17 слушатель висел на document; теперь — на контейнере из createRoot. Так два React-приложения на одной странице не перехватывают клики друг друга.

Если слушатель на корне, то и объект события должен быть «корневым»: в делегировании из части II currentTarget был контейнером, а не кнопкой. Посмотрим, что React передаёт в обработчик. Пять строк вывода — попробуйте угадать каждую.

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

Объект в обработчике — не MouseEvent браузера, а обёртка React; в документации её называют синтетическим событием. Настоящее событие лежит внутри, в nativeEvent, и у него currentTarget честный — корень, где висит слушатель. А у обёртки currentTarget подменён на элемент, на котором написан onClick: код в обработчике выглядит так, будто слушатель стоит на кнопке. Это та самая работа closest, только сделанная за вас.

Остальное знакомо по части II: type, target (клик пришёл по <span> внутри кнопки — ровно так же он приходил и в нативный обработчик), preventDefault и stopPropagation — о них статья «Всплытие, отмена и остановка в React». Обёртка нужна React, чтобы событие вело себя одинаково во всех браузерах и чтобы подменить currentTarget; ничего сверх этого она не прячет.

В React до 17-й версии объекты событий переиспользовались: после выхода из обработчика поля обнулялись, и чтобы прочитать событие в таймере, звали event.persist(). Начиная с 17 обёртка живёт как обычный объект — читать её из setTimeout можно, а persist() оставлен пустышкой ради совместимости. Встретите его в чужом коде — можно просто удалить.

nativeEvent пригодится, когда настоящий MouseEvent нужен чужой функции — например, вашему помощнику из части II, который ждёт именно его.

В прошлом примере у event нет аннотации, но event.currentTarget.id скомпилировался, а event.target потребовал приведения. Тип пришёл из места: onClick у <button> ждёт React.MouseEventHandler<HTMLButtonElement>, и параметр стрелки выводится из него — то самое наведение типа из части III. Оно работает, пока стрелка написана прямо в атрибуте.

Вынесите функцию наружу — и выводить тип станет неоткуда.

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

Знакомая диагностика TS7006 из части III, и лечение то же — аннотация. Вопрос только, какая. Тип MouseEvent вы знаете с части II, и рука тянется к нему первой.

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

Компилятор раскладывает причину по вложенности, как в статье про чтение диагностик: функции несовместимы, потому что несовместимы параметры, а параметры — потому что у обёртки React нет полей нативного события вроде offsetX. MouseEvent без префикса — тип DOM. В обработчик приезжает React.MouseEvent, и в его аргументе указывают элемент, на котором стоит onClick: он становится типом currentTarget.

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

Второй клик не напечатал ничего: выключенная кнопка событий не порождает. А disabled нашёлся без единой проверки именно благодаря аргументу дженерика — currentTarget здесь HTMLButtonElement, а не EventTarget.

ПропТип событияЧто в аргументе
onClick, onMouseDown, onMouseEnterReact.MouseEvent<T>элемент с обработчиком: HTMLButtonElement, HTMLLIElement
onKeyDown, onKeyUpReact.KeyboardEvent<T>чаще всего HTMLInputElement
onChange, onInputReact.ChangeEvent<T>HTMLInputElement — подробно в статье про управляемые поля
onSubmitReact.FormEvent<HTMLFormElement>форма
onFocus, onBlurReact.FocusEvent<T>поле или кнопка

У каждого типа события есть парный тип обработчика: React.MouseEventHandler<T>, React.ChangeEventHandler<T> и так далее. Если обработчик — константа со стрелкой, аннотацию удобнее повесить на неё целиком: параметр выведется из типа функции, как выводился из атрибута.

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

Наведите в редакторе на event внутри инлайн-стрелки: подсказка покажет полный тип, и его можно скопировать в аннотацию вынесенной функции.

  • Обработчик в JSX — проп: в фигурных скобках лежит сама функция, а не строка с кодом, и имя пишется в camelCase — onClick, а не onclick.
  • onClick={save()} вызывает save при отрисовке и передаёт в проп undefined; нужен аргумент — оборачивайте вызов в стрелку.
  • На кнопке слушателя нет: React вешает один нативный слушатель на корень из createRoot и сам ищет, чей onClick вызвать, — это делегирование из части II, сделанное за вас.
  • В обработчик приходит синтетическое событие: обёртка над настоящим (оно в nativeEvent), у которой currentTarget подменён на элемент с обработчиком.
  • Инлайн-стрелка получает тип события из атрибута; вынесенной функции нужна аннотация React.MouseEvent<HTMLButtonElement>, а не DOM-овский MouseEvent.
  • Аргумент дженерика — элемент, на котором стоит обработчик: он становится типом currentTarget, и disabled или textContent доступны без сужения.

1/4В разметке написано <button onClick={save()}>, где save печатает «сохраняем» и ничего не возвращает. Что произойдёт?

2/4Где на самом деле висит слушатель клика, когда в JSX у кнопки написан onClick?

3/4У кнопки с onClick внутри лежит <span>, и клик пришёл по нему. Что окажется в event.currentTarget внутри обработчика?

4/4Обработчик вынесен в отдельную функцию function handleClick(event) и передан в onClick кнопки. Какая аннотация подойдёт параметру?

Список, который пустеет сам

Кнопки «убрать» должны удалять свою строку по клику, но список пустеет ещё до первого клика. Прочитайте диагностику компилятора, объясните себе, почему строк ноль, и почините обработчик.

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

remove(title) вызывается при сборке разметки: первая же отрисовка удаляет «Кофе», следующая — «Чай», и список пустеет за две отрисовки, ни разу не дождавшись клика. В onClick при этом уезжает undefined — компилятор так и говорит: void не может быть MouseEventHandler. Нужна стрелка: она создаётся при отрисовке, а remove зовёт только по клику.

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

Заметьте, что бесконечного цикла не случилось: каждая лишняя отрисовка убирала по строке, и на пустом массиве вызовы кончились. С setCount(count + 1) в том же месте React остановил бы цикл сам — ошибкой «Too many re-renders».

Обработчик пропсом в свой компонент

Соберите переключатель из проекта части II на компонентах. Допишите тип пропсов Row — title строка, done логическое, onToggle функция без аргументов — и передайте из Board обработчик, который переключает done. Сейчас компилятор жалуется на параметры, а кнопка ничего не делает.

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

Тип пропсов объявляем интерфейсом рядом с компонентом; обработчик в нём — обычный тип функции. Из Board передаём стрелку, которая замыкает done и setDone текущей отрисовки.

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

Сравните с частью II: там кнопка несла data-action="toggle", обработчик на контейнере искал её через closest, доставал id из dataset и находил задачу в массиве. Здесь Row ничего не знает о состоянии — ей просто отдали функцию, и она зовёт её по клику.

Аннотация для вынесенного обработчика

Обработчик вынесен из разметки, и компилятор жалуется на его параметр. Допишите аннотацию так, чтобы диагностик не осталось, а currentTarget считался именно элементом списка — не EventTarget и не кнопкой.

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

Обработчик стоит на <li>, значит, в аргумент React.MouseEvent идёт HTMLLIElement — он и станет типом currentTarget.

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

Написать HTMLButtonElement компилятор не даст: onClick у <li> ждёт обработчик для элемента списка, и в диагностике будет видно, что HTMLLIElement не совпадает с HTMLButtonElement. А DOM-овский MouseEvent отвалится по той же причине, что и в статье: у обёртки React нет его полей.