Skip to main content

Синтетичні події в React

SyntheticEvent - це крос-браузерна обгортка React навколо нативних DOM-подій. Вона нормалізує властивості на зразок event.target.value так, щоб вони однаково працювали в Chrome, Firefox та старому IE, і використовує один кореневий слухач замість по одного на кожен елемент.

Теорія

TL;DR

  • Нативні події в різних браузерах мають трохи різні властивості. SyntheticEvent дає один стабільний API поверх усього цього.
  • React чіпляє ОДИН слухач до #root, а не по одному на кожен елемент. Це і є делегування подій.
  • У React 16 об'єкти подій повторно використовувались після завершення обробника, і асинхронний доступ до event.target повертав null. React 17 прибрав цю поведінку.
  • e.target - елемент, що спричинив подію. e.currentTarget - елемент де знаходиться обробник.
  • e.nativeEvent повертає сирий DOM-об'єкт браузера, коли він потрібен сторонній бібліотеці.

Швидкий приклад

javascript
function Button() { const handleClick = (e) => { console.log(e.type); // "click" - нормалізовано console.log(e.nativeEvent); // MouseEvent - сирий DOM-об'єкт e.preventDefault(); // однаково в будь-якому браузері }; return <a href="/somewhere" onClick={handleClick}>Клік</a>; } // Скасовує навігацію, виводить "click" без браузерних відмінностей

e який React передає в обробник - не нативний MouseEvent. Це обгортка над ним. Та сама форма, ті самі методи, плюс e.nativeEvent щоб дістатися оригіналу.

Як працює делегування подій

React не чіпляє окремий слухач до кожної кнопки, інпуту чи посилання. Він реєструє один слухач на кореневому DOM-вузлі (#root у React 17+, document у React 16). Нативні події спливають по DOM як завжди. React перехоплює їх у корені, огортає в SyntheticEvent, нормалізує властивості і доставляє до потрібного обробника.

Список з 1000 кнопок все одно матиме рівно один слухач у браузері. Динамічні списки обробляють кліки без жодного перепідписування. Перехід від document до #root у React 17 важливий при змішуванні версій React на одній сторінці. Кожне React-дерево тепер ізольоване.

Пулінг подій: React 16 проти React 17

У React 16 об'єкти SyntheticEvent були в пулі. Після завершення обробника React обнуляв усі властивості і повертав об'єкт у пул для повторного використання. Це непомітно ламало асинхронний код:

javascript
// React 16 - ламається function handleClick(e) { setTimeout(() => { console.log(e.target); // null - об'єкт вже переробили }, 0); }

Виправлення - e.persist(), який виймав подію з пулу:

javascript
// React 16 - виправлено function handleClick(e) { e.persist(); setTimeout(() => { console.log(e.target); // <button> }, 0); }

React 17 прибрав пулінг повністю. Тепер події - звичайні об'єкти. persist() залишився як no-op для зворотної сумісності. Я достатньо разів бачив цей async-null баг у реальних логах, щоб тепер завжди зчитувати значення подій синхронно. Безпечний патерн для обох версій:

javascript
function handleClick(e) { const value = e.target.value; // зчитуємо синхронно setTimeout(() => console.log(value), 0); // завжди працює }

event.target проти event.currentTarget

Уяви кнопку зі span всередині:

javascript
<button onClick={handle}> <span>Клік сюди</span> </button>

Юзер натиснув на <span>. Тепер e.target - це <span>, а e.currentTarget - це <button>. Обробник на кнопці, але клік потрапив на span. Для форм завжди читай з e.currentTarget, якщо потрібен елемент з обробником, а не той на який натиснули.

Типові помилки

Читання властивостей події в асинхронному коді без попереднього захоплення.

javascript
// Ламалось у React 16, працює у React 17+ але залишається нечітким const handleChange = (e) => { setTimeout(() => setState(e.target.value), 0); }; // Явно і безпечно скрізь const handleChange = (e) => { const val = e.target.value; setTimeout(() => setState(val), 0); };

Використання addEventListener у useEffect для React-елементів.

javascript
// Не роби так - обходить делегування, може спрацювати двічі, тече пам'ять useEffect(() => { document.getElementById('btn').addEventListener('click', handler); }); // Роби так <button onClick={handler}>Клік</button>

addEventListener у useEffect доречний для подій на window - resize, scroll - де немає відповідного React-пропсу. Але завжди повертай функцію очищення.

Повернення false для скасування дефолтної дії - як у звичайному HTML. У React return false з обробника нічого не робить. Потрібно явно викликати e.preventDefault().

Плутанина між e.target і e.currentTarget у обробниках форм. Якщо поле форми містить вкладені елементи, e.target може вказувати на дочірній span. Для елемента з обробником завжди використовуй e.currentTarget.

Де зустрічається

  • React-компоненти - кожен onClick, onChange, onSubmit отримує SyntheticEvent за замовчуванням
  • Next.js - обробники форм в app router працюють так само прозоро
  • Material UI - SyntheticEvent проходить з компонентів бібліотеки у твої обробники без змін
  • Інтеграція з D3.js - використовуй e.nativeEvent коли D3 очікує сирий DOM-об'єкт
  • TypeScript - імпортуй MouseEvent, ChangeEvent, FormEvent з react, не з DOM-типів

Питання на співбесіді

Q: Яка різниця між event.target та event.currentTarget?


A: target - елемент що отримав подію, тобто найглибший з натиснутих. currentTarget - елемент де прикріплений обробник. Різняться коли натиснутий елемент є дочірнім відносно елемента з обробником.

Q: Чому React 17 переніс слухачі подій з document на #root?


A: Прикріплення до document спричиняло конфлікти при роботі двох версій React на одній сторінці або при змішуванні з іншими фреймворками. Перенос на кореневий вузол ізолює кожне React-дерево.

Q: Чи потрібен event.persist() у React 17+?


A: Ні. Пулінг прибрали, тому persist() нічого не робить. У старих кодових базах зустрічається - безпечно видалити, залишати теж не шкідливо.

Q: Як stopPropagation() працює всередині синтетичної системи React?


A: Виклик e.stopPropagation() виставляє внутрішній прапор у циклі dispatch React. React припиняє виклик батьківських обробників. Нативна подія все одно досягає кореня де сидить єдиний слухач, але далі React її не диспатчить.

Q (senior): Як SyntheticEvent поводиться з порталами?


A: Події з елементів у порталі спливають крізь React-дерево компонентів, а не по DOM-дереву. Клік всередині модального вікна-порталу спрацює на React-батьку, навіть якщо DOM порталу знаходиться за межами того вузла. Для отримання реального DOM-шляху використовуй e.nativeEvent.

Приклади

Базовий: скасування навігації

javascript
function NavLink() { const handleClick = (e) => { e.preventDefault(); // зупиняє навігацію браузера console.log(e.type); // "click" console.log(e.currentTarget.href); // href посилання }; return <a href="/dashboard" onClick={handleClick}>Дашборд</a>; } // Виводить "click" і href, перезавантаження немає

e.currentTarget тут завжди буде тегом <a>, незалежно від того який дочірній елемент всередині нього натиснули.

Середній рівень: асинхронна відправка форми

javascript
function PaymentForm({ onSubmit }) { const [card, setCard] = useState(''); const handleChange = (e) => { setCard(e.target.value); // синхронне читання - безпечно в будь-якій версії }; const handleSubmit = async (e) => { e.preventDefault(); // card береться зі стану, а не з об'єкта події // тому проблем з async немає const res = await fetch('/charge', { method: 'POST', body: JSON.stringify({ card }), }); onSubmit(await res.json()); }; return ( <form onSubmit={handleSubmit}> <input value={card} onChange={handleChange} placeholder="Номер картки" /> <button type="submit">Оплатити</button> </form> ); } // handleChange зчитує event.target.value синхронно і зберігає в стані. // Асинхронний обробник взагалі не торкається об'єкта події.

Просунутий: фаза захоплення

javascript
function CaptureDemo() { return ( <div onClickCapture={() => console.log('1. div capture')} onClick={() => console.log('4. div bubble')} > <button onClickCapture={() => console.log('2. button capture')} onClick={() => console.log('3. button bubble')} > Клік </button> </div> ); } // Результат при натисканні кнопки: // 1. div capture // 2. button capture // 3. button bubble // 4. div bubble // // React підтримує фазу захоплення через суфікс *Capture на будь-якому пропсі події. // Корисно для перехоплення події до того як вона дістанеться дочірнього обробника.

Обробники з захопленням спрацьовують зверху вниз до того як елемент отримує подію. Це дозволяє батьківському компоненту перехопити і скасувати подію до того як будь-який дочірній компонент її оброблятиме.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?