React Typescript

Розділення подій та ефектів: реактивна проти нереактивної логіки

Глибоке розуміння різниці між обробниками подій та ефектами: коли код має бути реактивним, а коли ні. Практичні патерни збереження нереактивної логіки через рефи та майбутній useEffectEvent.

Розділення подій та ефектів: реактивна проти нереактивної логіки

🔗 Офіційний референс: Розділення подій та ефектів (Separating Events from Effects)

🎯 Мета лекції

  • Зрозуміти фундаментальну різницю між логікою обробки подій та логікою синхронізації ефектів.
  • Опанувати концепцію реактивних залежностей: що повинно перезапускати ефект, а що ні.
  • Навчитися виявляти ситуації, коли ефект потребує свіжих даних без реактивного прив'язування.
  • Застосувати патерн збереження нереактивної логіки через рефи для уникнення зайвих ре-синхронізацій.

🔑 Ключові терміни

  • Обробник події (Event Handler): функція, що запускається виключно у відповідь на конкретну дію користувача (клік, введення, submit форми).
  • Реактивна залежність (Reactive Dependency): змінна, зміна якої призводить до повторного виконання ефекту.
  • Нереактивна логіка (Non-Reactive Logic): код, який потребує свіжих даних, але не повинен перезапускати синхронізацію.
  • Ref-патерн (Ref Pattern): техніка збереження актуальних значень без включення їх до масиву залежностей.

Дві категорії логіки у React: події проти синхронізації

У попередніх лекціях ми встановили, що React-компоненти містять два типи коду:

  1. Логіка рендерингу — чисті обчислення, які приймають пропси та стан і повертають JSX-розмітку.
  2. Побічні ефекти — усе інше: мережеві запити, підписки, маніпуляція DOM.

Але всередині побічних ефектів існує ще один критичний поділ, про який часто забувають початківці:

Існує два фундаментально різних види побічних ефектів:
  • Обробники подій (Event Handlers) — запускаються виключно як реакція на конкретну взаємодію користувача (клік кнопки, відправка форми, натискання клавіші). Вони не є реактивними — їх виконання не залежить від змін пропсів або стану автоматично.
  • Ефекти (Effects) — запускаються автоматично щоразу, коли змінюються реактивні залежності. Вони є реактивними — їхня мета полягає в підтриманні синхронізації компонента з зовнішньою системою.

Розуміння цієї різниці критично важливе для написання правильного коду. Початківці часто плутають ці два типи логіки, що призводить до зайвих повторних підключень, витоків пам'яті або застарілих замикань (stale closures).


Обробники подій: нереактивна логіка за визначенням

Обробники подій запускаються виключно тоді, коли користувач виконує певну дію. Вони не «слідкують» за змінами пропсів або стану — вони просто виконуються один раз у відповідь на подію.

interface ProductCardProps {
  productId: string;
  productName: string;
  theme: "light" | "dark";
}

function ProductCard({ productId, productName, theme }: ProductCardProps) {
  function handleAddToCart() {
    // Ця функція виконується ЛИШЕ при кліку на кнопку
    // Вона прочитає актуальні значення productId та theme
    console.log(`Додано товар ${productId} до кошика (тема: ${theme})`);
    
    // Відправка аналітичної події з поточною темою
    trackEvent("add_to_cart", {
      product_id: productId,
      theme: theme,
    });
  }

  return (
    <div className={`product-card ${theme}`}>
      <h3>{productName}</h3>
      <button onClick={handleAddToCart}>Додати до кошика</button>
    </div>
  );
}
Чому обробники подій завжди читають свіжі значення?Кожен рендер компонента створює нову версію функції handleAddToCart із замкненими (captured) значеннями productId, productName та theme з поточного рендера. Коли користувач натискає кнопку, React викликає останню версію обробника, яка має доступ до найсвіжіших даних. Це відбувається автоматично завдяки механізму замикань JavaScript.

Ефекти: реактивна синхронізація з зовнішніми системами

На відміну від обробників подій, ефекти призначені для автоматичної синхронізації компонента з зовнішніми системами (чат-сервер, браузерний API, WebSocket, таймери).

Розглянемо класичний приклад — підключення до чат-кімнати:

import { useEffect } from "react";

interface ChatRoomProps {
  roomId: string;
}

function createConnection(roomId: string) {
  return {
    connect() {
      console.log(`✅ Підключення до кімнати "${roomId}"`);
    },
    disconnect() {
      console.log(`❌ Відключення від кімнати "${roomId}"`);
    },
  };
}

export function ChatRoom({ roomId }: ChatRoomProps) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [roomId]); // ← Ефект реагує на зміну roomId

  return <h3>Кімната: {roomId}</h3>;
}

Що відбувається при зміні roomId з "general" на "travel":

  1. React виявляє зміну реактивної залежності roomId.
  2. React спочатку викликає функцію очищення (disconnect від старої кімнати "general").
  3. React потім запускає новий ефект (connect до нової кімнати "travel").

Це є реактивною поведінкою — ефект автоматично повторно синхронізується щоразу, коли змінюється roomId.

Ключова відмінність: обробники подій запускаються вручну (користувач натиснув кнопку), а ефекти запускаються автоматично (React виявив зміну залежності).

Проблема: коли логіка має бути частково реактивною

Тепер уявімо реальну ситуацію: при підключенні до чату ми хочемо показати сповіщення (notification), і це сповіщення повинно мати колір, який залежить від поточної теми інтерфейсу (theme: "light" | "dark").

Наївна спроба виглядатиме так:

interface ChatRoomProps {
  roomId: string;
  theme: "light" | "dark";
}

function showNotification(message: string, theme: "light" | "dark") {
  console.log(`[${theme}] ${message}`);
}

export function ChatRoom({ roomId, theme }: ChatRoomProps) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    // Показуємо сповіщення з урахуванням поточної теми
    showNotification(`Підключено до кімнати ${roomId}`, theme);

    return () => {
      connection.disconnect();
    };
  }, [roomId, theme]); // ❌ Додали theme до залежностей

  return <h3>Кімната: {roomId}</h3>;
}
Проблема цього підходу:Тепер ефект залежить і від roomId, і від theme. Це означає, що коли користувач просто перемикає світлу/темну тему (не змінюючи кімнату), React:
  1. Розірве TCP/WebSocket-з'єднання з чат-сервером.
  2. Створить нове підключення до тієї самої кімнати.
  3. Користувач на мить втратить зв'язок, отримає event «reconnected», пропустить повідомлення.
Це абсурд — перемикання теми інтерфейсу не повинно розривати мережеве з'єднання!

Ось у чому полягає дилема:

  • Ефект потребує свіжого значення theme, щоб правильно показати сповіщення.
  • Але зміна theme не повинна перезапускати весь ефект (розривати сокет та повторно підключатися).

Як бути? Нам потрібна логіка, яка:

  • Реагує на зміну roomId (реактивна частина).
  • Читає актуальне значення theme (нереактивна частина).

Патерн рішення: збереження нереактивної логіки через рефи

Щоб вирішити цю проблему, використовують патерн із useRef — зберігаємо актуальне значення у рефі, яка оновлюється при кожному рендері, але не викликає повторного запуску ефекту:

import { useEffect, useRef } from "react";

export function ChatRoom({ roomId, theme }: ChatRoomProps) {
  // Створюємо рефу для зберігання актуальної теми
  const themeRef = useRef(theme);
  
  // Оновлюємо рефу при кожному рендері
  // Це синхронно, тому ref.current завжди містить найсвіжіше значення
  themeRef.current = theme;

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    // Читаємо theme із рефи, а не напряму із пропсів
    showNotification(`Підключено до кімнати ${roomId}`, themeRef.current);

    return () => {
      connection.disconnect();
    };
  }, [roomId]); // ✅ Тільки roomId у залежностях!

  return <h3>Кімната: {roomId}</h3>;
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "Компонент ChatRoom" #E2E8F0 {
  rectangle "themeRef.current = theme" #DBEAFE
  note right
    Оновлюється **синхронно** при кожному рендері.
    Не викликає повторного запуску ефекту.
  end note
  
  rectangle "useEffect(() => {...}, [roomId])" #DCFCE7 {
    rectangle "connection.connect()" #FEF3C7
    rectangle "showNotification(..., themeRef.current)" #FEF3C7
    rectangle "return () => connection.disconnect()" #FEF3C7
  }
  
  note bottom of "useEffect(() => {...}, [roomId])"
    Запускається **лише** при зміні roomId.
    Читає свіжий theme із рефи **без реактивного прив'язування**.
  end note
}

@enduml

Що відбувається тепер:

  1. При кожному рендері (навіть якщо тільки theme змінилася) рядок themeRef.current = theme синхронно оновлює значення у рефі.
  2. Ефект запускається виключно при зміні roomId, бо тільки він у масиві залежностей.
  3. Коли ефект виконується, він читає актуальне значення themeRef.current, яке гарантовано містить останню тему.
  4. Якщо theme змінюється (користувач перемикає світлу/темну тему), реф оновлюється, але ефект не перезапускається — з'єднання залишається активним.
Чому themeRef.current = theme потрібно писати поза useEffect?Оновлення .current має відбуватися синхронно під час фази рендерингу, до того, як React запустить ефект. Якщо помістити це присвоювання всередину ефекту, ви отримаєте застаріле значення при першому запуску ефекту після зміни теми.

Приклад із реального світу: аналітика та чат

Розглянемо більш складний практичний сценарій — коли користувач переходить між чат-кімнатами, ми хочемо:

  1. Записати подію аналітики room_visited з переданням поточної кімнати та теми інтерфейсу.
  2. Підключитися до WebSocket-серверу кімнати.

Неправильний підхід (обидві залежності в одному ефекті):

// ❌ ПОГАНО: theme викликає розрив сокета
useEffect(() => {
  const socket = connectToWebSocket(roomId);
  
  // Аналітика з урахуванням теми
  trackEvent("room_visited", { roomId, theme });
  
  return () => {
    socket.close();
  };
}, [roomId, theme]); // Зміна theme розриває сокет!

Правильний підхід (рефа для теми):

interface ChatAnalyticsProps {
  roomId: string;
  theme: "light" | "dark";
}

export function ChatWithAnalytics({ roomId, theme }: ChatAnalyticsProps) {
  const themeRef = useRef(theme);
  themeRef.current = theme;

  useEffect(() => {
    const socket = connectToWebSocket(roomId);
    
    // Читаємо свіжу тему з рефи, але вона не є залежністю
    trackEvent("room_visited", {
      roomId,
      theme: themeRef.current, // Завжди актуальна тема
    });
    
    socket.on("message", (msg: string) => {
      console.log(`[${themeRef.current}] ${msg}`);
    });
    
    return () => {
      socket.close();
    };
  }, [roomId]); // Тільки roomId перезапускає ефект

  return <div className={`chat-room ${theme}`}>...</div>;
}
Чому це працює безпечно?
  • Функція trackEvent викликається один раз при підключенні до кімнати — вона фіксує стан теми в цей момент.
  • Обробник socket.on("message", ...) читає themeRef.current при кожному отриманні повідомлення, тому завжди бачить свіжу тему.
  • Зміна theme не розриває WebSocket-з'єднання, бо theme не є залежністю ефекту.

Майбутнє: експериментальний хук useEffectEvent

Команда React розробляє новий хук useEffectEvent, який вирішуватиме цю проблему більш елегантно. Наразі він знаходиться у статусі RFC (Request for Comments) і недоступний у стабільній версії React.

Концепція useEffectEvent:

import { useEffect, experimental_useEffectEvent as useEffectEvent } from "react";

export function ChatRoom({ roomId, theme }: ChatRoomProps) {
  // Оголошуємо "подію ефекту" — функцію, яка НЕ є реактивною
  const onConnected = useEffectEvent(() => {
    showNotification(`Підключено до ${roomId}`, theme);
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    // Викликаємо подію ефекту — вона завжди бачить свіжі значення
    onConnected();
    
    return () => {
      connection.disconnect();
    };
  }, [roomId]); // theme не потрібна у залежностях!

  return <h3>Кімната: {roomId}</h3>;
}
Станом на React 19.1 useEffectEvent досі є експериментальним API.Його не можна використовувати у продакшн-коді без явного імпорту з react/experimental. Документація рекомендує поки що застосовувати патерн із рефами, описаний вище. Слідкуйте за офіційними анонсами — коли API стабілізується, його варто буде застосовувати для кращої читабельності коду.

Ключові правила вибору: подія чи ефект?

Щоб не помилитися під час проєктування логіки компонента, задайте собі це питання:

Крок 1: Логіка виконується у відповідь на конкретну взаємодію користувача?

Якщо так — це обробник події.

Приклади:

  • Відправка форми → onSubmit
  • Клік на кнопку «Завантажити файл» → onClick
  • Зміна значення інпуту → onChange

Крок 2: Логіка повинна синхронізувати компонент із зовнішньою системою?

Якщо так — це ефект.

Приклади:

  • Підключення до WebSocket при зміні кімнати.
  • Підписка на події клавіатури при монтуванні компонента.
  • Запуск таймера, який оновлює поточний час кожну секунду.

Крок 3: Чи потрібна логіці свіжа змінна, яка не повинна перезапускати синхронізацію?

Якщо так — використайте патерн рефи (useRef для збереження актуального значення).


Практичне завдання: відстеження перегляду сторінки з темою


Типові помилки та антипатерни

Помилка 1: Вимикання лінтера exhaustive-deps

useEffect(() => {
  trackEvent("room_visited", { roomId, theme });
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId]); // ❌ Лінтер скаже, що theme має бути у залежностях
Директива eslint-disable-next-line приховує проблему, але не вирішує її. Якщо theme змінюється між рендерами, але ефект не перезапускається, функція всередині ефекту читатиме застаріле (stale) значення theme із замикання попереднього рендера. Це призведе до помилок: аналітична подія буде відправлена з неправильною темою.

Правильне рішення: використати патерн рефи.


Помилка 2: Створення об'єкта або функції всередині залежностей

useEffect(() => {
  const options = { roomId, theme }; // ❌ Новий об'єкт при кожному рендері
  trackEvent("room_visited", options);
}, [options]); // React бачить різні посилання на об'єкти

Кожен рендер створює новий об'єкт options із новим посиланням у пам'яті, тому options ніколи не дорівнює options з попереднього рендера (навіть якщо вміст ідентичний). Це призводить до запуску ефекту при кожному рендері.

Правильне рішення: якщо об'єкт потрібен лише цьому ефекту, створіть його всередині ефекту:

useEffect(() => {
  const options = { roomId, theme };
  trackEvent("room_visited", options);
}, [roomId, theme]); // Залежність від примітивів, а не від об'єкта

Помилка 3: Змішування реактивної та нереактивної логіки в одному ефекті

useEffect(() => {
  const socket = connectToRoom(roomId); // Реактивна частина (має перезапускатися)
  const userPreferences = fetchUserPrefs(userId); // Нереактивна частина (достатньо один раз)
  return () => socket.close();
}, [roomId, userId]); // userId викликає розрив сокета!

Правильне рішення: розділіть на два незалежних ефекти:

// Ефект 1: підключення до кімнати (реагує на roomId)
useEffect(() => {
  const socket = connectToRoom(roomId);
  return () => socket.close();
}, [roomId]);

// Ефект 2: завантаження налаштувань (реагує на userId)
useEffect(() => {
  fetchUserPrefs(userId);
}, [userId]);

Додаткові приклади використання патерну рефи

Приклад 1: Логування кліків із поточною темою

interface ButtonWithLoggingProps {
  onClick: () => void;
  theme: "light" | "dark";
  children: React.ReactNode;
}

export function ButtonWithLogging({
  onClick,
  theme,
  children,
}: ButtonWithLoggingProps) {
  const themeRef = useRef(theme);
  themeRef.current = theme;

  function handleClick() {
    // Виконуємо оригінальний обробник
    onClick();
    
    // Логуємо подію з актуальною темою
    console.log(`[${themeRef.current}] Кнопка "${children}" натиснута`);
  }

  return <button onClick={handleClick}>{children}</button>;
}
У цьому прикладі реф не використовується всередині useEffect, а читається в обробнику події handleClick. Це демонструє, що патерн рефи корисний не тільки для ефектів — він підходить для будь-якого випадку, коли потрібно прочитати свіжі дані без створення нових замикань.

Приклад 2: Таймер із динамічним інтервалом

Розглянемо компонент, що показує оновлюваний лічильник. Інтервал оновлення передається через пропс, але зміна інтервалу не повинна скидати лічильник до нуля.

import { useState, useEffect, useRef } from "react";

interface AutoCounterProps {
  intervalMs: number; // Інтервал оновлення в мілісекундах
}

export function AutoCounter({ intervalMs }: AutoCounterProps) {
  const [count, setCount] = useState(0);
  const intervalRef = useRef(intervalMs);
  
  // Оновлюємо рефу при кожному рендері
  intervalRef.current = intervalMs;

  useEffect(() => {
    // Створюємо таймер один раз при монтуванні
    const id = setInterval(() => {
      setCount((prev) => prev + 1);
      
      // Можна використати intervalRef.current для логування
      console.log(`Оновлення через ${intervalRef.current}мс`);
    }, intervalRef.current);

    return () => {
      clearInterval(id);
    };
  }, []); // Порожній масив залежностей — таймер створюється один раз!

  return (
    <div>
      <p>Лічильник: {count}</p>
      <p>Інтервал: {intervalMs}мс</p>
    </div>
  );
}
Цей приклад демонструє важливу особливість:Таймер створюється один раз при монтуванні компонента з початковим значенням intervalRef.current. Якщо потім змінити intervalMs, intervalRef.current оновиться, але існуючий таймер продовжить працювати з початковим інтервалом, бо setInterval було викликано лише один раз.Якщо потрібно, щоб зміна intervalMs перестворювала таймер із новим інтервалом, додайте intervalMs до залежностей:
useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + 1);
  }, intervalMs);

  return () => clearInterval(id);
}, [intervalMs]); // Тепер таймер перестворюється при зміні інтервалу
Вибір підходу залежить від бізнес-логіки: чи потрібно зберігати існуючий таймер незмінним (рефа), чи його слід перезапускати (залежність).

Візуалізація різниці: обробники подій проти ефектів

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Компонент ChatRoom" #F1F5F9 {
  
  rectangle "Пропси: roomId, theme" #DBEAFE
  
  rectangle "Обробник події onClick" #DCFCE7 {
    note right
      **Нереактивний**
      Запускається лише при кліку користувача.
      Читає свіжі значення з замикання.
      Не залежить від масиву залежностей.
    end note
    
    rectangle "handleButtonClick() {\n  trackEvent(roomId, theme);\n}" #FEF3C7
  }
  
  rectangle "Ефект useEffect" #FFE4E6 {
    note right
      **Реактивний**
      Запускається автоматично при зміні roomId.
      Потребує функції очищення.
      Залежить від масиву залежностей.
    end note
    
    rectangle "useEffect(() => {\n  const socket = connect(roomId);\n  socket.on('msg', () => {\n    console.log(themeRef.current);\n  });\n  return () => socket.close();\n}, [roomId]);" #FEF3C7
  }
  
  rectangle "Реф для нереактивних даних" #E0E7FF {
    rectangle "themeRef.current = theme" #C7D2FE
    note bottom
      Оновлюється синхронно.
      Не викликає повторного рендера.
      Дозволяє читати свіжі дані без залежності.
    end note
  }
}

@enduml

Інтерактивна демонстрація патерну

React Розділення подій та ефектів
Файли проєкту
src
components
ChatRoom.tsx
App.tsx
styles.css
localhost:3000

Збирання React застосунку…

Що демонструє цей приклад:

  1. При виборі іншої кімнати у селекті — консоль покаже ❌ Відключення та потім ✅ Підключення до нової кімнати.
  2. При натисканні кнопки перемикання теми — сповіщення у консолі не з'являться, бо ефект не перезапускається.
  3. themeRef.current завжди містить актуальну тему, тому якщо додати логування всередині обробника події (наприклад, при надсиланні повідомлення), воно покаже правильну тему.

Резюме: коли використовувати який підхід

✅ Використовуйте обробники подій

  • Код запускається у відповідь на конкретну взаємодію користувача.
  • Приклади: клік кнопки, відправка форми, натискання клавіші.
  • Завжди читають свіжі значення завдяки замиканням.
  • Не є реактивними — не залежать від змін пропсів автоматично.

✅ Використовуйте ефекти

  • Код синхронізує компонент із зовнішньою системою.
  • Приклади: підключення до WebSocket, підписка на події, таймери.
  • Запускаються автоматично при зміні залежностей.
  • Є реактивними — залежать від масиву залежностей.

✅ Використовуйте рефи для нереактивних даних

  • Ефект потребує свіжих даних, але зміна цих даних не повинна перезапускати синхронізацію.
  • Приклади: тема інтерфейсу для аналітики, налаштування користувача для логування.
  • Патерн: const ref = useRef(value); ref.current = value; поза ефектом, читання ref.current всередині ефекту.

Практичні завдання для самостійного опрацювання


Підсумок лекції

✅ Ви навчилися

  • Розрізняти обробники подій (нереактивні, запускаються вручну) та ефекти (реактивні, запускаються автоматично).
  • Виявляти ситуації, коли ефект потребує свіжих даних без реактивного прив'язування.
  • Застосовувати патерн рефи для збереження нереактивної логіки: const ref = useRef(value); ref.current = value;.
  • Уникати типових помилок: вимикання лінтера, створення об'єктів у залежностях, змішування реактивної та нереактивної логіки.

🔮 Наступні кроки

У наступній лекції ми вивчимо видалення зайвих залежностей ефектів — як рефакторити код, щоб масив залежностей містив мінімально необхідний набір змінних, та як використовувати функціональне оновлення стану для повного усунення залежностей.
Copyright © 2026