Коротка відповідь. 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

Два класичні «лікування» – і обидва погані:

  1. Додати theme у deps. Лінтер задоволений, але тепер перемикання світлої/темної теми фізично перепідключає WebSocket. Користувач міняє тему – чат блимає.
  2. Вимкнути правило лінтера. Замикання лишається протухлим, а лінтер більше не допоможе, коли ти додаси в ефект щось справді важливе.

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 – ця стаття продовжує саме ту модель.

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

  1. Виклик Effect Event в onClick: це звичайна подія компонента, а не подія ефекту – пиши звичайний обробник.
  2. Передача Effect Event у кастомний хук або дочірній компонент: лінтер застогне, і правильно зробить.
  3. Загортання всього тіла ефекту: якщо в Effect Event опинилась і підписка, і відписка – ти знову вимкнув реактивність, лише складнішим шляхом.
  4. Використання до React 19.2 за гайдами з експериментального періоду: перевір версію і онови eslint-plugin-react-hooks, інакше правила хука не контролюються.

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

Якщо готуєшся до React-інтерв'ю (решта тем – у гайді з підготовки), ось що я питав би по цій темі: чому не можна покласти Effect Event у deps; чим він відрізняється від useCallback із порожніми deps; що станеться зі stale closure, якщо замість нього використати ref; і улюблене – «покажи ефект, де useEffectEvent зашкодить».

Практичне завдання

Знайди у своєму проєкті ефект, у deps якого більше трьох значень. Розпиши кожне: воно reactive (має перезапускати ефект) чи подієве (лише читається)? Винеси подієві в useEffectEvent, обнови deps і перевір у React DevTools, скільки разів ефект реально перезапускається до і після. Якщо deps стали чесними, а перезапуски – рідшими, ти щойно полагодив те, що раніше «лікував» вимкненим лінтером.