# useEffectEvent: як прибрати stale closures і не перепідключати половину застосунку

> Розбираємо useEffectEvent із React 19.2: чому зміна теми не повинна reconnect-ити WebSocket, чим Effect Event відрізняється від useCallback і useRef, і коли цей хук стає способом сховати баг.

- Автор: Юра Скиба (https://cookiesoftware.io)
- Опубліковано: 2026-10-01
- Категорія: React і Frontend
- Canonical: https://cookiesoftware.io/blog/react-useeffectevent

---

**Коротка відповідь.** `useEffectEvent` (стабільний із React 19.2) розв'язує конфлікт, який роками мучив React-розробників: ефект має бачити актуальні пропси й стейт, але не має перезапускатись від кожного з них. Ти загортаєш «подієву» частину ефекту в Effect Event – вона завжди читає свіжі значення і не потрапляє в залежності. Підключення чату більше не рветься від зміни теми. Але є межа: якщо ти використовуєш цей хук, щоб просто прибрати попередження лінтера про залежності – ти не виправив баг, а сховав його.

## Звідки береться stale closure

Кожен рендер компонента створює нові функції, і кожна з них «запам'ятовує» значення пропсів і стейту саме свого рендера. Ефект, який запустився один раз, тримає замикання на старі значення:

```tsx
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: «ця функція належить ефекту, але читає світ на момент виклику»:

```tsx
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, вміє приховувати справжні баги:

```tsx
// 🔴 Антипатерн: візит логується один раз і бреше далі
const logVisit = useEffectEvent(() => {
  analytics.pageView(pageUrl);
});

useEffect(() => {
  logVisit();
}, []); // pageUrl змінився – а логу немає
```

Перехід на інший URL тут – справжня причина перезапустити ефект: це reactive-значення, і воно має бути в deps. Тест на чесність перед кожним використанням: «якщо це значення зміниться, чи має побічний ефект відбутися ще раз?» Так – значення лишається в deps. Ні, воно лише читається в момент події – тоді Effect Event.

Про те, коли ефект взагалі не потрібен і що таке його життєвий цикл, я детально писав у розборі [useEffect vs useLayoutEffect](/blog/useeffect-uselayouteffect) – ця стаття продовжує саме ту модель.

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

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

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

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

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

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