React Typescript

Життєвий цикл реактивних ефектів: мислення процесами синхронізації

Глибоке занурення в життєвий цикл ефектів React: як React керує повторною синхронізацією, чому залежності критично важливі, та як працює лінтер exhaustive-deps для запобігання багам.

Життєвий цикл реактивних ефектів: мислення процесами синхронізації

🔗 Офіційний референс: Життєвий цикл реактивних ефектів (Lifecycle of Reactive Effects)

🎯 Мета лекції

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

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

  • Реактивне значення (Reactive Value): будь-яка змінна, оголошена всередині компонента, що може змінюватися між рендерами.
  • Застаріле замикання (Stale Closure): ситуація, коли функція всередині ефекту читає застаріле значення реактивної змінної.
  • Exhaustive Dependencies: правило лінтера, що вимагає включення всіх реактивних значень у масив залежностей.
  • Повторна синхронізація (Re-synchronization): процес зупинки попереднього ефекту та запуску нового з актуальними даними.

Компоненти мають життєвий цикл, але ефекти мають інший

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

  1. Монтування (Mount): компонент з'являється на екрані вперше.
  2. Оновлення (Update): компонент перерендерюється через зміну пропсів або стану.
  3. Розмонтування (Unmount): компонент видаляється з DOM назавжди.

Початківці часто намагаються мислити про ефекти в тих самих категоріях: «цей ефект запускається при монтуванні», «цей — при оновленні», «цей — при розмонтуванні». Проте це хибна ментальна модель, яка призводить до багів.

Нова парадигма мислення: Ефект не думає категоріями «перший рендер чи п'ятий». Ефект вміє лише дві речі:
  • Почати синхронізацію із зовнішньою системою (підключитися до сервера, підписатися на подію, запустити анімацію).
  • Припинити синхронізацію (від'єднатися від сервера, відписатися від події, зупинити анімацію).
Кількість циклів початку/зупинки залежить виключно від залежностей, а не від кількості рендерів компонента.

Життєвий цикл ефекту: початок і кінець синхронізації

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

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]);

  return <h3>Ласкаво просимо до кімнати: {roomId}</h3>;
}

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

  1. React рендерить компонент із новим roomId: "travel".
  2. React бачить, що значення roomId у масиві залежностей змінилося.
  3. React спочатку викликає функцію очищення попереднього ефекту із старим roomId: "general":
    ❌ Відключення від кімнати "general"
    
  4. React потім виконує новий ефект із актуальним roomId: "travel":
    ✅ Підключення до кімнати "travel"
    
React Перемикання кімнат чату
Файли проєкту
src
App.tsx
styles.css
localhost:3000

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

Ключовий момент: React не пам'ятає, скільки разів компонент рендерився. React бачить лише актуальні значення залежностей і порівнює їх із попередніми через Object.is(). Якщо хоча б одна залежність змінилася, React виконує цикл cleanup → setup знову.

Відкрийте консоль браузера у прикладі вище та швидко перемикайте кімнати. Ви побачите, що кожна зміна спричиняє послідовність:
❌ Відключення від кімнати "general"
✅ Підключення до кімнати "travel"
❌ Відключення від кімнати "travel"
✅ Підключення до кімнати "music"
Це доводить, що React завжди очищає попередній ефект перед запуском нового.

Реактивні значення: що повинно бути у залежностях

Реактивне значення (reactive value) — це будь-яка змінна, оголошена всередині тіла компонента, яка може змінюватися між рендерами. Це включає:

  • Пропси: props.userId, props.theme, тощо.
  • Стан: змінні, повернуті з useState.
  • Локальні змінні: будь-яка змінна, обчислена з пропсів або стану.
function ChatRoom({ roomId, serverUrl }: { roomId: string; serverUrl: string }) {
  const [isConnected, setIsConnected] = useState(false);
  
  // roomId, serverUrl, isConnected — всі реактивні!
  const connectionStatus = isConnected ? "підключено" : "відключено";
  
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    setIsConnected(true);

    return () => {
      connection.disconnect();
      setIsConnected(false);
    };
  }, [roomId, serverUrl]); // Обидва пропси обов'язкові!

  return <p>Статус: {connectionStatus}</p>;
}
Що не є реактивним значенням:
  • Константи та змінні поза компонентом: Якщо змінна оголошена за межами тіла функції компонента, вона не змінюється між рендерами і не потребує включення у залежності.
  • Рефи (useRef): Об'єкт рефа стабільний між рендерами, тому .current можна читати в ефекті без додавання до залежностей (проте зміна .current не перезапускає ефект).
  • Функції-сеттери стану: setCount, setIsConnected тощо завжди стабільні і не потребують включення.
// ✅ Ці значення не є реактивними:
const API_URL = "https://api.example.com"; // Поза компонентом
const timeoutRef = useRef<number | null>(null); // Реф
const [count, setCount] = useState(0); // setCount стабільна

useEffect(() => {
  // Можна використовувати без додавання до залежностей
  fetch(API_URL);
  timeoutRef.current = setTimeout(() => {}, 1000);
  setCount(10);
}, []);

Правило вичерпних залежностей (Exhaustive Dependencies Rule)

ESLint має спеціальне правило для React — react-hooks/exhaustive-deps, яке аналізує код всередині useEffect та перевіряє, чи всі реактивні значення присутні у масиві залежностей. Це правило запобігає одній із найпоширеніших категорій багів у React: застарілим замиканням (stale closures).

Застаріле замикання: приклад бага

import { useState, useEffect } from "react";

export function CounterBug() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      // ❌ БАГ: count завжди буде 0, оскільки замикання захопило початкове значення
      console.log(`Поточний лічильник: ${count}`);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // Порожній масив — ефект виконується лише один раз

  return (
    <div>
      <p>Лічильник: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

Що тут не так:

  1. Ефект запускається один раз при монтуванні, коли count === 0.
  2. Колбек setInterval захоплює значення count у замиканні — воно «заморожується» на 0.
  3. Навіть коли користувач збільшує лічильник до 5, інтервал продовжує виводити Поточний лічильник: 0.
Консоль браузера (Bug)
$ Користувач натискає +1 тричі
⚠️ Поточний лічильник: 0
⚠️ Поточний лічильник: 0
⚠️ Поточний лічильник: 0
(count дорівнює 3, але інтервал читає застаріле значення 0)

Виправлення через додавання залежності

import { useState, useEffect } from "react";

export function CounterFixed() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      // ✅ Тепер count завжди актуальне
      console.log(`Поточний лічильник: ${count}`);
    }, 1000);

    return () => clearInterval(intervalId);
  }, [count]); // Додали count у залежності

  return (
    <div>
      <p>Лічильник: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

Проте це рішення має побічний ефект: Тепер ефект перезапускається кожного разу, коли count змінюється. Це означає, що інтервал скидається — якщо він мав вивести повідомлення через 500 мс, але count змінився раніше, інтервал зупиниться та запуститься знову з нульової мітки.

Ідеальне рішення через функціональне оновлення

import { useState, useEffect } from "react";

export function CounterBest() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      // ✅ Використовуємо функціональну форму для читання актуального значення
      setCount((currentCount) => {
        console.log(`Поточний лічильник: ${currentCount}`);
        return currentCount; // Не змінюємо значення, лише читаємо
      });
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // Порожній масив — ефект виконується лише один раз

  return (
    <div>
      <p>Лічильник: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
}

Тепер інтервал створюється лише один раз, але завжди читає актуальне значення count через функціональне оновлення стану.

Коли використовувати функціональне оновлення: Якщо ефект повинен читати актуальне значення стану, але не повинен перезапускатися при його зміні, використовуйте функціональну форму setState((prev) => ...). Це дозволяє читати свіже значення без додавання його до залежностей.

Лінтер exhaustive-deps: автоматичне виявлення помилок

Правило react-hooks/exhaustive-deps аналізує код ефекту та видає попередження, якщо:

  1. Забута залежність: Ви читаєте реактивне значення всередині ефекту, але не додали його до масиву залежностей.
  2. Зайва залежність: Ви додали у залежності значення, яке не є реактивним (константу поза компонентом).
  3. Нестабільна залежність: Ви додали об'єкт або функцію, що створюється при кожному рендері.

Приклад попередження лінтера

function ChatRoom({ roomId }: { roomId: string }) {
  const [message, setMessage] = useState("");

  useEffect(() => {
    const connection = connectToChat(roomId);
    connection.send(message); // Читаємо message

    return () => connection.close();
  }, [roomId]); // ⚠️ React Hook useEffect has a missing dependency: 'message'

  return <input value={message} onChange={(e) => setMessage(e.target.value)} />;
}

Що каже лінтер:

React Hook useEffect has a missing dependency: 'message'.
Either include it or remove the dependency array.

Два варіанти виправлення:

  1. Додати message у залежності (якщо ефект справді повинен перезапускатися при зміні повідомлення):
    useEffect(() => {
      const connection = connectToChat(roomId);
      connection.send(message);
      return () => connection.close();
    }, [roomId, message]);
    
  2. Перемістити логіку в обробник події (якщо відправка повинна відбуватися лише при натисканні кнопки, а не при кожній зміні):
    const handleSend = () => {
      const connection = connectToChat(roomId);
      connection.send(message);
    };
    
Ніколи не вимикайте лінтер через eslint-disable!
// ❌ ПОГАНО: ігнорування попередження лінтера
useEffect(() => {
  doSomething(value);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
Якщо лінтер скаржиться, це означає, що ваш код має потенційний баг. Замість вимикання правила, виправте код одним із способів:
  • Додайте пропущені залежності.
  • Винесіть статичний код за межі компонента.
  • Використайте useRef для збереження нереактивних значень.
  • Використайте функціональне оновлення стану.

Порівняння залежностей: як React визначає зміни

React порівнює кожне значення у масиві залежностей із попереднім значенням за допомогою алгоритму Object.is(). Це строге порівняння, еквівалентне ===, але з особливими випадками для NaN та -0.

Object.is(5, 5);           // true
Object.is("hello", "hello"); // true
Object.is(true, false);    // false
Object.is({}, {});         // false (різні об'єкти!)
Object.is([], []);         // false (різні масиви!)
Object.is(NaN, NaN);       // true (відмінність від ===)

Проблема нестабільних об'єктів та масивів

function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);

  useEffect(() => {
    // ❌ БАГ: options — новий об'єкт при кожному рендері!
    const options = { category: "electronics", sort: "price" };
    
    fetchProducts(options).then(setProducts);
  }, [options]); // ⚠️ options — нестабільна залежність

  // ...
}

Проблема: options створюється при кожному рендері. Навіть якщо його вміст ({ category: "electronics", sort: "price" }) однаковий, це різні об'єкти в пам'яті. Object.is(prevOptions, options) поверне false, і ефект запуститься знову, викликавши новий рендер → нескінченний цикл.

Рішення 1: Винести об'єкт за межі компонента

// ✅ Об'єкт створюється один раз, стабільне посилання
const OPTIONS = { category: "electronics", sort: "price" };

function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);

  useEffect(() => {
    fetchProducts(OPTIONS).then(setProducts);
  }, []); // Порожній масив — ефект виконується один раз
}

Рішення 2: Використати useMemo для стабілізації об'єкта

function ProductList({ category }: { category: string }) {
  const [products, setProducts] = useState<Product[]>([]);

  // ✅ options перестворюється лише при зміні category
  const options = useMemo(() => ({
    category,
    sort: "price"
  }), [category]);

  useEffect(() => {
    fetchProducts(options).then(setProducts);
  }, [options]);
}
Загальне правило: Якщо залежність — це об'єкт або масив, що створюється всередині компонента, використовуйте useMemo для стабілізації його посилання. Якщо залежність — це функція, використовуйте useCallback.

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

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

import { useState, useEffect } from "react";

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

function createConnection(serverUrl: string, roomId: string) {
  return {
    connect() {
      console.log(`✅ Підключення: ${serverUrl}/rooms/${roomId}`);
    },
    disconnect() {
      console.log(`❌ Відключення: ${serverUrl}/rooms/${roomId}`);
    },
    on(event: string, callback: () => void) {
      if (event === "message") {
        setTimeout(callback, 2000); // Симулюємо отримання повідомлення
      }
    },
  };
}

function showNotification(message: string, theme: "light" | "dark") {
  const bgColor = theme === "light" ? "#f0f0f0" : "#333";
  console.log(`🔔 [${theme} theme] ${message}`, `background: ${bgColor}`);
}

export function Chat({ roomId, serverUrl, theme }: ChatProps) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();

    connection.on("message", () => {
      showNotification(`Нове повідомлення у ${roomId}`, theme);
    });

    return () => {
      connection.disconnect();
    };
  }, [roomId, serverUrl, theme]); // Всі три значення реактивні!

  return (
    <div>
      <h3>Кімната: {roomId}</h3>
      <p>Сервер: {serverUrl}</p>
      <p>Тема: {theme}</p>
    </div>
  );
}

Проблема цього коду: Якщо користувач просто перемикає тему з "light" на "dark", чат відключається від сервера та підключається знову, хоча сокет-з'єднання не залежить від теми. Це зайва робота — ми розірвали з'єднання лише для того, щоб прочитати нове значення theme у колбеку сповіщень.

Рішення: У наступній лекції ми розглянемо техніки розділення подій та ефектів, що дозволяють читати актуальне значення theme без додавання його до залежностей ефекту.


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


Підсумок: ключові принципи життєвого циклу ефектів

🔄 Цикл синхронізації

Ефект не думає про монтування/оновлення/розмонтування. Він вміє лише:

  • Почати синхронізацію (setup)
  • Припинити синхронізацію (cleanup)

Кількість циклів залежить від змін у залежностях.

⚛️ Реактивні значення

Будь-яка змінна, оголошена всередині компонента, є реактивною:

  • Пропси
  • Стан
  • Локальні змінні

Всі реактивні значення, прочитані в ефекті, обов'язково мають бути у залежностях.

🔍 Правило exhaustive-deps

Лінтер автоматично виявляє:

  • Забуті залежності → застарілі замикання
  • Нестабільні об'єкти/функції → нескінченні цикли

Ніколи не ігноруйте попередження — виправте код.

🛠️ Техніки оптимізації

Для уникнення зайвих перезапусків:

  • Винесіть статичний код за межі компонента
  • Використайте useMemo для об'єктів
  • Використайте useCallback для функцій
  • Використайте useRef для нереактивних значень

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

У цій лекції ви опанували фундаментальні принципи життєвого циклу реактивних ефектів: як React порівнює залежності, чому виникають застарілі замикання, та як лінтер exhaustive-deps допомагає уникати багів.

  • Наступна лекція: «Розділення подій та ефектів» — розглянемо складнішу проблему: як прочитати свіже значення стану без додавання його до залежностей через техніку рефів та експериментальний хук useEffectEvent.
  • Подальші теми: видалення зайвих залежностей ефектів, кастомні хуки для інкапсуляції логіки синхронізації, розширені патерни роботи з асинхронними ефектами.
Практична рекомендація: Перегляньте існуючий код свого проєкту та знайдіть усі місця, де:
  1. Масив залежностей містить об'єкти або функції — можливо, вони створюються при кожному рендері.
  2. Лінтер видає попередження про пропущені залежності — не ігноруйте їх, виправте через одну з технік цієї лекції.
  3. Ефекти запускаються частіше, ніж потрібно — проаналізуйте залежності та оптимізуйте через useMemo/useCallback.
Copyright © 2026