Коротка відповідь. useEffectEvent (стабільний із React 19.2) розв'язує конфлікт, який роками мучив React-розробників: ефект має бачити актуальні пропси й стейт, але не має перезапускатись від кожного з них. Ти загортаєш «подієву» частину ефекту в Effect Event – вона завжди читає свіжі значення і не потрапляє в залежності. Підключення чату більше не рветься від зміни теми. Але є межа: якщо ти використовуєш цей хук, щоб просто прибрати попередження лінтера про залежності – ти не виправив баг, а сховав його.
Звідки береться stale closure
Кожен рендер компонента створює нові функції, і кожна з них «запам'ятовує» значення пропсів і стейту саме свого рендера. Ефект, який запустився один раз, тримає замикання на старі значення:
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
// theme тут – ЗАВЖДИ той, яким він був на момент запуску ефекту
showNotification('Підключено!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // eslint скаржиться: theme не в depsДва класичні «лікування» – і обидва погані:
- Додати
themeу deps. Лінтер задоволений, але тепер перемикання світлої/темної теми фізично перепідключає WebSocket. Користувач міняє тему – чат блимає. - Вимкнути правило лінтера. Замикання лишається протухлим, а лінтер більше не допоможе, коли ти додаси в ефект щось справді важливе.
Reactive і non-reactive частини ефекту
Ключова ідея, заради якої варто прочитати цю статтю: всередині одного ефекту живуть два різні види логіки.
- Reactive – те, через що ефект існує. Змінився
roomId→ треба перепідключитись. Це чесна залежність. - Non-reactive (подієва) – те, що ефект просто читає в момент події. Нотифікація про підключення хоче знати поточну
theme, але зміна теми не причина щось перезапускати.
useEffectEvent – це спосіб сказати React: «ця функція належить ефекту, але читає світ на момент виклику»:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }: { roomId: string; theme: 'light' | 'dark' }) {
const onConnected = useEffectEvent(() => {
// Завжди актуальна theme – без потрапляння в deps
showNotification('Підключено!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => onConnected());
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ лише справжня причина перезапуску
return <h1>Кімната {roomId}</h1>;
}Тепер зміна roomId перепідключає чат (так і треба), а зміна theme – ні. І лінтер щасливий чесно, а не силоміць.
Правила гри: де Effect Event можна, а де не можна
Документація тут категорична, і це варто знати напам'ять:
- Викликати Effect Event можна лише зсередини ефектів (
useEffect,useLayoutEffect,useInsertionEffect) або інших Effect Events того ж компонента. - Не можна викликати під час рендера, в обробниках подій, передавати в дочірні компоненти чи інші хуки.
- Не можна класти в масив залежностей – у Effect Event навмисно нестабільна ідентичність: вона міняється кожен рендер, щоб неправильне використання одразу боляче проявлялось, а не тихо працювало «майже завжди».
Останній пункт – найцікавіший дизайн-хід: замість стабільної ідентичності (як у setState чи ref) команда React зробила навпаки нестабільну, саме щоб ти не міг збудувати на ній щось глобальне.
useEffectEvent vs useCallback vs useRef
Усі три «стабілізують функції», тому їх плутають. Задачі в них різні:
| useEffectEvent | useCallback | useRef (latest-ref патерн) | |
|---|---|---|---|
| Задача | подієва логіка всередині ефекту | стабільний пропс для мемоізованих дітей | ручне «вікно» в актуальне значення |
| Читає актуальні значення | так, завжди | ні, лише з deps | так, але синхронізацію пишеш сам |
| Можна передати дитині | ні | так | так (сам ref) |
| Потрапляє в deps ефекту | ні | так | ref стабільний, тож ні |
| Лінтер розуміє | так | так | ні, патерн поза його моделлю |
Практичне правило: функція летить у memo-дитину – useCallback; функція є «подією» твого ефекту – useEffectEvent; ти на React < 19.2 – latest-ref патерн як ручна заміна, із розумінням, що лінтер тобі більше не асистент.
Головне зловживання: заткнути лінтер
Той самий механізм, який прибирає зайвий reconnect, вміє приховувати справжні баги:
// 🔴 Антипатерн: візит логується один раз і бреше далі
const logVisit = useEffectEvent(() => {
analytics.pageView(pageUrl);
});
useEffect(() => {
logVisit();
}, []); // pageUrl змінився – а логу немаєПерехід на інший URL тут – справжня причина перезапустити ефект: це reactive-значення, і воно має бути в deps. Тест на чесність перед кожним використанням: «якщо це значення зміниться, чи має побічний ефект відбутися ще раз?» Так – значення лишається в deps. Ні, воно лише читається в момент події – тоді Effect Event.
Про те, коли ефект взагалі не потрібен і що таке його життєвий цикл, я детально писав у розборі useEffect vs useLayoutEffect – ця стаття продовжує саме ту модель.
Типові помилки
- Виклик Effect Event в onClick: це звичайна подія компонента, а не подія ефекту – пиши звичайний обробник.
- Передача Effect Event у кастомний хук або дочірній компонент: лінтер застогне, і правильно зробить.
- Загортання всього тіла ефекту: якщо в Effect Event опинилась і підписка, і відписка – ти знову вимкнув реактивність, лише складнішим шляхом.
- Використання до React 19.2 за гайдами з експериментального періоду: перевір версію і онови
eslint-plugin-react-hooks, інакше правила хука не контролюються.
Питання для співбесіди
Якщо готуєшся до React-інтерв'ю (решта тем – у гайді з підготовки), ось що я питав би по цій темі: чому не можна покласти Effect Event у deps; чим він відрізняється від useCallback із порожніми deps; що станеться зі stale closure, якщо замість нього використати ref; і улюблене – «покажи ефект, де useEffectEvent зашкодить».
Практичне завдання
Знайди у своєму проєкті ефект, у deps якого більше трьох значень. Розпиши кожне: воно reactive (має перезапускати ефект) чи подієве (лише читається)? Винеси подієві в useEffectEvent, обнови deps і перевір у React DevTools, скільки разів ефект реально перезапускається до і після. Якщо deps стали чесними, а перезапуски – рідшими, ти щойно полагодив те, що раніше «лікував» вимкненим лінтером.