Обработчики в JSX против addEventListener
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части II обработчик жил отдельно от разметки: render() собирал строку, а addEventListener вешался на контейнер один раз и через closest угадывал, по чему кликнули. В React обработчик записывается прямо в разметке — onClick={handleClick}, — и это не возврат к onclick="..." из старых учебников, а то же делегирование, только спрятанное внутрь библиотеки. Разберём, что происходит между кликом и вызовом вашей функции и какие две ошибки компилятор ловит ещё до запуска.
В части II обработчик и разметка жили порознь. render() собирал строку и клал её в innerHTML, а слушатель висел на контейнере и через closest выяснял, по чему кликнули, — потому что всё, что было привязано к самим узлам, полная пересборка стирала. JSX эту разлуку отменяет: разметка здесь не строка, а вызов функции (статья «JSX — это не HTML»), и в атрибут можно положить функцию так же, как число или объект.
Два клика — две строки, и в каждой свой count: обработчик создаётся заново на каждой отрисовке и замыкает её снимок, как разобрано в главе про состояние. Новое здесь только одно — onClick={handleClick}: фигурные скобки, внутри имя функции без вызова. React запоминает функцию и позовёт её, когда придёт клик.
Имя пропса пишется в camelCase, и это не стилевая прихоть. Попробуйте написать так, как привыкли в HTML, — и предскажите, что на это скажут компилятор и сам React.
Компилятор не только отверг onclick, но и подсказал правильное имя: оно есть в типах <button>, а строчного там нет. Строку в выводе напечатал не он, а сам React — в песочнице стоит его отладочная сборка, и она ловит ту же опечатку теми же словами. Сам проп при этом пропал без следа: в разметке остаётся <button>Сохранить</button> — ни атрибута onclick, ни свойства, — а в боевой сборке React об этом молчит, и без компилятора опечатка просто тихо не работала бы. И заметьте разницу с HTML: там в кавычках лежал текст кода, здесь значением служит сама функция.
onClick, onChange, onSubmit, onKeyDown — имя события с заглавной буквы после on. Полный список живёт в типах React: редактор подскажет его, стоит набрать on.
Самая частая ошибка первой недели с React — скобки после имени обработчика. Перед запуском ответьте на два вопроса: когда напечатается «сохраняем» и что сделает клик.
«Сохраняем» напечаталось до всякого клика, а сам клик прошёл впустую. save() — вызов: он выполняется в момент сборки разметки, то есть прямо в отрисовке, а в onClick уезжает его результат — undefined. Компилятор говорит то же самое своими словами: void не годится там, где ждут MouseEventHandler. React получает undefined, и звать по клику ему некого.
Когда обработчику нужен аргумент, его заворачивают в стрелку: она создаётся при отрисовке, а вызывается по клику — обычный колбэк из части I. Ниже так передаётся обработчик в свой компонент: у пропса onRemove тип функции, как у любого колбэка из части III.
Строки «Чай» нет, две другие на месте. Каждая Row получила свою стрелку с замкнутым title. В части II за это отвечали data-id и closest("li"), и значение приезжало строкой — приходилось звать Number(). Замыкание отдаёт значение как есть, любого типа, и искать строку по DOM не нужно.
Если в скобках оказался не безобидный save, а setCount, вызов не просто бесполезен: setCount во время отрисовки требует новую отрисовку, та снова вызывает setCount, и так по кругу. React считает повторы и после двадцати пяти останавливает их ошибкой «Too many re-renders» — эту лестницу вы уже видели в статье «Триггер, рендер, коммит». Самого текста в выводе не окажется: браузер скрывает сообщения ошибок, прилетевших из подключённого со стороны файла, и на их месте стоит заглушка — разбор строка за строкой как раз там. Серия отрисовок при этом напечатается дважды: после сбоя React один раз пробует отрисовать заново, вдруг он был случайным. Диагностика компилятора здесь та же — void вместо обработчика, — и это тот случай, когда её стоит прочитать до запуска.
Раз обработчик записан в разметке, кажется, что React вешает его на кнопку — как addEventListener в части II. Проверим по DOM: есть ли у кнопки обработчик и кто ещё увидит клик.
Первый клик увидели двое: сначала React, потом нативный слушатель на корне — он подписался позже, а слушатель React повешен на тот же корень ещё в createRoot. Второй клик не увидел никто, хотя с кнопки ничего не снимали: она просто перестала быть внутри корня, и всплывающее событие больше через него не проходит. Так ведёт себя только делегирование — обработчик на самой кнопке пережил бы перенос. Это буквально ваш board.addEventListener("click", ...) из проекта части II: один слушатель на контейнере, а не подписка на каждую кнопку.
Если заглянуть в инспекторе, у кнопки с onClick найдётся свойство onclick — и в нём пустая функция. Это костыль для Safari на iOS: без onclick он не считает элемент кликабельным и не шлёт событие. Вашего обработчика в этой функции нет, и на перенос кнопки она не влияет, что пример и показал.
Разница в том, что делается дальше. Вы шли от event.target вверх через closest и читали dataset, чтобы понять, чья это кнопка. React идёт от того же target вверх по своему дереву элементов и вызывает onClick тех, у кого он записан. Таблица «кто за что отвечает» лежит не в DOM, а в описании разметки — поэтому перерисовка обработчики не теряет, а у нового элемента они есть с первой секунды.
| Делегирование из части II | onClick в React | |
|---|---|---|
| Где висит слушатель | на контейнере, вешаете сами | на корне из createRoot, один на все события |
| Как находится обработчик | target.closest(...) и проверка вручную | от target вверх по дереву элементов React |
| Что при перерисовке | узлы новые, слушатель на контейнере уцелел | обработчики — часть описания, обновляются вместе с разметкой |
| Откуда данные строки | dataset, всегда строками | из замыкания обработчика, любым типом |
| Новый элемент | работает, если подходит под closest | работает: у него свой onClick в описании |
До React 17 слушатель висел на document; теперь — на контейнере из createRoot. Так два React-приложения на одной странице не перехватывают клики друг друга.
Если слушатель на корне, то и объект события должен быть «корневым»: в делегировании из части II currentTarget был контейнером, а не кнопкой. Посмотрим, что React передаёт в обработчик. Пять строк вывода — попробуйте угадать каждую.
Объект в обработчике — не 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. Оно работает, пока стрелка написана прямо в атрибуте.
Вынесите функцию наружу — и выводить тип станет неоткуда.
Знакомая диагностика TS7006 из части III, и лечение то же — аннотация. Вопрос только, какая. Тип MouseEvent вы знаете с части II, и рука тянется к нему первой.
Компилятор раскладывает причину по вложенности, как в статье про чтение диагностик: функции несовместимы, потому что несовместимы параметры, а параметры — потому что у обёртки React нет полей нативного события вроде offsetX. MouseEvent без префикса — тип DOM. В обработчик приезжает React.MouseEvent, и в его аргументе указывают элемент, на котором стоит onClick: он становится типом currentTarget.
Второй клик не напечатал ничего: выключенная кнопка событий не порождает. А disabled нашёлся без единой проверки именно благодаря аргументу дженерика — currentTarget здесь HTMLButtonElement, а не EventTarget.
| Проп | Тип события | Что в аргументе |
|---|---|---|
onClick, onMouseDown, onMouseEnter | React.MouseEvent<T> | элемент с обработчиком: HTMLButtonElement, HTMLLIElement |
onKeyDown, onKeyUp | React.KeyboardEvent<T> | чаще всего HTMLInputElement |
onChange, onInput | React.ChangeEvent<T> | HTMLInputElement — подробно в статье про управляемые поля |
onSubmit | React.FormEvent<HTMLFormElement> | форма |
onFocus, onBlur | React.FocusEvent<T> | поле или кнопка |
У каждого типа события есть парный тип обработчика: React.MouseEventHandler<T>, React.ChangeEventHandler<T> и так далее. Если обработчик — константа со стрелкой, аннотацию удобнее повесить на неё целиком: параметр выведется из типа функции, как выводился из атрибута.
Наведите в редакторе на 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 кнопки. Какая аннотация подойдёт параметру?
Список, который пустеет сам
Кнопки «убрать» должны удалять свою строку по клику, но список пустеет ещё до первого клика. Прочитайте диагностику компилятора, объясните себе, почему строк ноль, и почините обработчик.
remove(title) вызывается при сборке разметки: первая же отрисовка удаляет «Кофе», следующая — «Чай», и список пустеет за две отрисовки, ни разу не дождавшись клика. В onClick при этом уезжает undefined — компилятор так и говорит: void не может быть MouseEventHandler. Нужна стрелка: она создаётся при отрисовке, а remove зовёт только по клику.
Заметьте, что бесконечного цикла не случилось: каждая лишняя отрисовка убирала по строке, и на пустом массиве вызовы кончились. С setCount(count + 1) в том же месте React остановил бы цикл сам — ошибкой «Too many re-renders».
Обработчик пропсом в свой компонент
Соберите переключатель из проекта части II на компонентах. Допишите тип пропсов Row — title строка, done логическое, onToggle функция без аргументов — и передайте из Board обработчик, который переключает done. Сейчас компилятор жалуется на параметры, а кнопка ничего не делает.
Тип пропсов объявляем интерфейсом рядом с компонентом; обработчик в нём — обычный тип функции. Из Board передаём стрелку, которая замыкает done и setDone текущей отрисовки.
Сравните с частью II: там кнопка несла data-action="toggle", обработчик на контейнере искал её через closest, доставал id из dataset и находил задачу в массиве. Здесь Row ничего не знает о состоянии — ей просто отдали функцию, и она зовёт её по клику.
Аннотация для вынесенного обработчика
Обработчик вынесен из разметки, и компилятор жалуется на его параметр. Допишите аннотацию так, чтобы диагностик не осталось, а currentTarget считался именно элементом списка — не EventTarget и не кнопкой.
Обработчик стоит на <li>, значит, в аргумент React.MouseEvent идёт HTMLLIElement — он и станет типом currentTarget.
Написать HTMLButtonElement компилятор не даст: onClick у <li> ждёт обработчик для элемента списка, и в диагностике будет видно, что HTMLLIElement не совпадает с HTMLButtonElement. А DOM-овский MouseEvent отвалится по той же причине, что и в статье: у обёртки React нет его полей.