React Typescript

Видалення зайвих залежностей ефектів та оптимізація синхронізації

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

Видалення зайвих залежностей ефектів та оптимізація синхронізації

🔗 Офіційний референс: Видалення залежностей ефектів (Removing Effect Dependencies)

🎯 Мета лекції

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

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

  • Exhaustive Dependencies: правило ESLint, що вимагає включення всіх реактивних значень у масив залежностей.
  • Застаріле замикання (Stale Closure): ситуація, коли ефект читає значення з попереднього рендера замість актуального.
  • Функціональне оновлення стану (Functional State Update): передача функції prev => nextValue у setter стану замість нового значення напряму.
  • Стабільне посилання (Stable Reference): об'єкт або функція, посилання на які не змінюється між рендерами.

Проблема: коли лінтер скаржиться на пропущені залежності

Уявімо типовий сценарій — ви пишете ефект, що підключається до чат-кімнати, і лінтер React Hooks видає попередження:

interface ChatRoomProps {
  roomId: string;
  serverUrl: string;
}

function ChatRoom({ roomId, serverUrl }: ChatRoomProps) {
  const options = {
    serverUrl: serverUrl,
    roomId: roomId,
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ⚠️ React Hook useEffect has a missing dependency: 'options'

  return <h3>Кімната: {roomId}</h3>;
}
Типова помилка початківця:Багато розробників у цьому місці просто додають коментар:
}, [roomId]); // eslint-disable-next-line react-hooks/exhaustive-deps
Це небезпечно! Директива eslint-disable приховує проблему, але не вирішує її. Якщо serverUrl змінюється між рендерами, ефект продовжуватиме використовувати застаріле (stale) значення serverUrl із замикання попереднього рендера. Це призведе до того, що компонент залишиться підключеним до старого сервера, навіть якщо пропс serverUrl змінився.

Правильне питання не «як вимкнути лінтер», а «як переписати код так, щоб залежність перестала бути проблемою».


Стратегія 1: Винесення статичного коду за межі компонента

Якщо об'єкт, функція або будь-яке значення не залежать від пропсів або стану, їх слід оголосити поза компонентом. У такому випадку їхнє посилання залишається стабільним між рендерами, і вони не є реактивними значеннями.

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

// ✅ Константа оголошена поза компонентом — стабільне посилання
const DEFAULT_OPTIONS = {
  reconnectInterval: 3000,
  maxRetries: 5,
  timeout: 10000,
};

function NetworkMonitor() {
  useEffect(() => {
    const monitor = createMonitor(DEFAULT_OPTIONS);
    monitor.start();
    
    return () => {
      monitor.stop();
    };
  }, []); // DEFAULT_OPTIONS не є залежністю!

  return <div>Моніторинг мережі активний</div>;
}
Чому це працює?Об'єкт DEFAULT_OPTIONS створюється один раз при завантаженні модуля. Його посилання в пам'яті залишається незмінним протягом усього життя застосунку. React розпізнає, що це значення не є реактивним (не оголошене всередині компонента), і не вимагає додавати його до залежностей.

Приклад: допоміжні функції без доступу до стану

// ✅ Функція не використовує пропси чи стан — стабільне посилання
function createChatConnection(roomId: string, serverUrl: string) {
  return {
    connect() {
      console.log(`Підключення до ${serverUrl}/${roomId}`);
    },
    disconnect() {
      console.log(`Відключення від ${roomId}`);
    },
  };
}

function ChatRoom({ roomId, serverUrl }: ChatRoomProps) {
  useEffect(() => {
    const connection = createChatConnection(roomId, serverUrl);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]); // createChatConnection не є залежністю!

  return <h3>Кімната: {roomId}</h3>;
}
Зверніть увагу: createChatConnection оголошена поза компонентом, тому її посилання стабільне. Але аргументи, які ми їй передаємо (roomId, serverUrl), є реактивними — вони приходять із пропсів, тому мають бути у масиві залежностей.

Стратегія 2: Переміщення створення об'єкта всередину ефекту

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

Проблема: об'єкт створюється при кожному рендері

function ChatRoom({ roomId, serverUrl }: ChatRoomProps) {
  // ❌ Новий об'єкт при кожному рендері — нове посилання в пам'яті
  const options = {
    serverUrl: serverUrl,
    roomId: roomId,
  };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // ⚠️ options змінюється при кожному рендері — ефект запускається щоразу!
}

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

При кожному рендері створюється новий об'єкт options із новим посиланням у пам'яті. Навіть якщо serverUrl та roomId не змінилися, React бачить різні посилання на об'єкти (options !== options), тому запускає ефект повторно.

Рішення: створити об'єкт всередині ефекту

function ChatRoom({ roomId, serverUrl }: ChatRoomProps) {
  useEffect(() => {
    // ✅ Об'єкт створюється всередині ефекту
    const options = {
      serverUrl: serverUrl,
      roomId: roomId,
    };
    
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]); // Залежність від примітивів, а не від об'єкта!

  return <h3>Кімната: {roomId}</h3>;
}
Чому це краще?Тепер ефект залежить від примітивних значеньroomId та serverUrl, а не від об'єкта options. Примітиви порівнюються за значенням (===), тому ефект запускається лише тоді, коли реально змінюється roomId або serverUrl, а не при кожному рендері.

Стратегія 3: Функціональне оновлення стану для усунення залежності від стану

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

Проблема: таймер залежить від стану

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ Читаємо count — він має бути у залежностях
    }, 1000);

    return () => clearInterval(id);
  }, [count]); // ⚠️ Таймер перестворюється щосекунди!

  return <h1>{count}</h1>;
}

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

  1. При кожному тіку таймера (кожну секунду) setCount(count + 1) оновлює стан.
  2. React перерендерює компонент із новим значенням count.
  3. Ефект бачить зміну залежності count і перестворює таймер (очищає старий setInterval, створює новий).
  4. Таймер ніколи не працює більше однієї секунди — кожну секунду він створюється заново.

Це не тільки неефективно, а й потенційно небезпечно: якщо очищення має побічні ефекти (наприклад, розрив WebSocket-з'єднання), вони виконуватимуться щосекунди.

Рішення: функціональне оновлення стану

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount((prevCount) => prevCount + 1); // ✅ Не читаємо count напряму
    }, 1000);

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

  return <h1>{count}</h1>;
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "Компонент Counter (count = 0)" #E2E8F0 {
  rectangle "useEffect(() => {...}, [])" #DBEAFE {
    note right
      Виконується **один раз** при монтуванні.
      Таймер більше ніколи не перестворюється.
    end note
    
    rectangle "setInterval(() => {\n  setCount(prev => prev + 1);\n}, 1000);" #DCFCE7
    
    note bottom of "setInterval(() => {\n  setCount(prev => prev + 1);\n}, 1000);"
      Кожна секунда викликається функція-оновлювач.
      React передає актуальне значення count у параметр prev.
      Замикання не зберігає застаріле значення count.
    end note
  }
}

rectangle "React State Management" #FEF3C7 {
  rectangle "t=0s: prev=0 → count=1" #DCFCE7
  rectangle "t=1s: prev=1 → count=2" #DCFCE7
  rectangle "t=2s: prev=2 → count=3" #DCFCE7
  rectangle "t=3s: prev=3 → count=4" #DCFCE7
}

@enduml

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

  1. Ефект запускається один раз при монтуванні компонента (порожній масив залежностей []).
  2. Таймер створюється один раз і більше ніколи не очищується до розмонтування компонента.
  3. Кожну секунду викликається setCount((prevCount) => prevCount + 1).
  4. React автоматично передає актуальне значення count у параметр prevCount функції-оновлювача.
  5. Замикання не зберігає застаріле значення count — кожен виклик отримує свіжий prevCount від React.
Золоте правило функціонального оновлення:Якщо новий стан обчислюється на основі попереднього (next = prev + delta), завжди використовуйте функціональне оновлення: setState(prev => prev + delta). Це усуває залежність від поточного стану і дозволяє ефекту працювати з порожнім масивом залежностей.

Стратегія 4: Розділення одного ефекту на кілька незалежних

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

Проблема: один ефект для двох незалежних систем

function Dashboard({ userId, theme }: { userId: string; theme: string }) {
  useEffect(() => {
    // Система 1: завантаження даних користувача
    fetchUserData(userId).then((data) => {
      console.log("Дані користувача завантажені:", data);
    });

    // Система 2: відправка події аналітики з темою
    trackEvent("dashboard_viewed", { userId, theme });
    
    // Система 3: підписка на оновлення профілю
    const unsubscribe = subscribeToProfileUpdates(userId, (update) => {
      console.log("Оновлення профілю:", update);
    });

    return () => {
      unsubscribe();
    };
  }, [userId, theme]); // ⚠️ Зміна theme перезавантажує дані користувача!

  return <div>Панель користувача</div>;
}

Проблема:

Коли користувач просто перемикає тему інтерфейсу (світла/темна), ефект:

  1. Відписується від оновлень профілю.
  2. Повторно завантажує дані користувача з сервера (навіть якщо userId не змінився).
  3. Повторно відправляє подію аналітики.
  4. Повторно підписується на оновлення профілю.

Це марнує ресурси та може призвести до блимання інтерфейсу.

Рішення: три окремих ефекти з точковими залежностями

function Dashboard({ userId, theme }: { userId: string; theme: string }) {
  // Ефект 1: завантаження даних користувача (залежить тільки від userId)
  useEffect(() => {
    fetchUserData(userId).then((data) => {
      console.log("Дані користувача завантажені:", data);
    });
  }, [userId]);

  // Ефект 2: аналітика (залежить від обох, але не має очищення)
  useEffect(() => {
    trackEvent("dashboard_viewed", { userId, theme });
  }, [userId, theme]);

  // Ефект 3: підписка на оновлення (залежить тільки від userId)
  useEffect(() => {
    const unsubscribe = subscribeToProfileUpdates(userId, (update) => {
      console.log("Оновлення профілю:", update);
    });

    return () => {
      unsubscribe();
    };
  }, [userId]);

  return <div>Панель користувача</div>;
}
Чому це краще?Тепер кожен ефект синхронізує одну конкретну зовнішню систему і має мінімальний набір залежностей:
  • Ефект 1 та 3 запускаються лише при зміні userId (новий користувач увійшов).
  • Ефект 2 запускається при зміні або userId, або theme, але не має функції очищення — він просто відправляє подію аналітики.
Зміна theme більше не викликає повторне завантаження даних або повторну підписку.

Практичний приклад: оптимізація чат-застосунку

Розглянемо реальний кейс — компонент чату, що поєднує кілька систем синхронізації.

Неоптимальна версія (один великий ефект)

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

function Chat({ roomId, userId, theme }: ChatProps) {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    // Система 1: WebSocket-підключення до кімнати
    const socket = connectToRoom(roomId);
    socket.on("message", (msg: string) => {
      setMessages((prev) => [...prev, msg]);
    });

    // Система 2: завантаження історії повідомлень
    fetchMessageHistory(roomId, userId).then((history) => {
      setMessages(history);
    });

    // Система 3: відправка події аналітики
    trackEvent("room_joined", { roomId, userId, theme });

    return () => {
      socket.disconnect();
    };
  }, [roomId, userId, theme]); // ⚠️ Зміна theme або userId розриває сокет!

  return (
    <div className={`chat ${theme}`}>
      {messages.map((msg, i) => (
        <div key={i}>{msg}</div>
      ))}
    </div>
  );
}

Проблеми:

  • Зміна userId (користувач увійшов під іншим акаунтом) розриває WebSocket-з'єднання навіть із тією самою кімнатою.
  • Зміна theme (користувач перемикає світлу/темну тему) розриває WebSocket-з'єднання.
  • Історія повідомлень завантажується повторно при кожній зміні будь-якої залежності.

Оптимізована версія (три окремих ефекти)

function Chat({ roomId, userId, theme }: ChatProps) {
  const [messages, setMessages] = useState<string[]>([]);

  // Ефект 1: WebSocket-підключення (залежить тільки від roomId)
  useEffect(() => {
    const socket = connectToRoom(roomId);
    socket.on("message", (msg: string) => {
      setMessages((prev) => [...prev, msg]); // Функціональне оновлення!
    });

    return () => {
      socket.disconnect();
    };
  }, [roomId]); // Тільки roomId — зміна userId або theme не розриває сокет

  // Ефект 2: завантаження історії (залежить від roomId та userId)
  useEffect(() => {
    fetchMessageHistory(roomId, userId).then((history) => {
      setMessages(history);
    });
  }, [roomId, userId]);

  // Ефект 3: аналітика з темою (патерн рефи з попередньої лекції)
  const themeRef = useRef(theme);
  themeRef.current = theme;

  useEffect(() => {
    trackEvent("room_joined", {
      roomId,
      userId,
      theme: themeRef.current, // Читаємо свіжу тему без залежності
    });
  }, [roomId, userId]);

  return (
    <div className={`chat ${theme}`}>
      {messages.map((msg, i) => (
        <div key={i}>{msg}</div>
      ))}
    </div>
  );
}
Комбінація стратегій:
  • Ефект 1: функціональне оновлення стану (setMessages(prev => [...])) дозволяє не включати messages у залежності.
  • Ефект 2: точкові залежності — історія завантажується лише при зміні roomId або userId.
  • Ефект 3: патерн рефи (із попередньої лекції) дозволяє прочитати свіжу theme без включення її у залежності.

Таблиця стратегій: коли застосовувати який підхід

СитуаціяСтратегіяПриклад
Об'єкт або функція не використовують пропси/стан1. Винести за межі компонентаconst API_CONFIG = { url: "..." } оголосити поза компонентом
Об'єкт потрібен лише одному ефекту2. Перемістити всередину ефектуuseEffect(() => { const opts = { roomId }; ... }, [roomId])
Ефект читає стан для обчислення наступного значення3. Функціональне оновленняsetCount(prev => prev + 1) замість setCount(count + 1)
Один ефект синхронізує кілька незалежних систем4. Розділити на окремі ефектиОкремі useEffect для WebSocket, аналітики, завантаження даних
Ефект потребує свіжих даних без реактивностіПатерн рефи (лекція 30)const ref = useRef(value); ref.current = value; useEffect(() => { log(ref.current); }, [])

Типові антипатерни та як їх виправити

Антипатерн 1: Вимикання лінтера без розуміння проблеми

// ❌ ПОГАНО: приховування проблеми замість вирішення
useEffect(() => {
  fetchData(userId, config);
  // eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId]); // config пропущено — ризик застарілого замикання!

Правильне рішення:

Якщо config статичний — винесіть за межі компонента. Якщо динамічний — створіть всередині ефекту або додайте до залежностей.


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

// ❌ ПОГАНО: зайва складність
const options = useMemo(() => ({
  serverUrl,
  roomId,
}), [serverUrl, roomId]);

useEffect(() => {
  const conn = createConnection(options);
  conn.connect();
  return () => conn.disconnect();
}, [options]);

Правильне рішення:

Просто створіть об'єкт всередині ефекту — це простіше та ефективніше:

// ✅ ДОБРЕ: простіше і без useMemo
useEffect(() => {
  const options = { serverUrl, roomId };
  const conn = createConnection(options);
  conn.connect();
  return () => conn.disconnect();
}, [serverUrl, roomId]);
Коли useMemo справді потрібен:useMemo має сенс для обчислювально важких операцій (сортування великих масивів, складні математичні обчислення), а не для створення простих об'єктів. Створення об'єкта { a, b } виконується за наносекунди — useMemo тут додає більше накладних витрат, ніж економить.

Антипатерн 3: Створення функцій всередині компонента, які потім стають залежностями

function SearchResults({ query }: { query: string }) {
  // ❌ Нова функція при кожному рендері
  const performSearch = () => {
    return fetch(`/api/search?q=${query}`).then((r) => r.json());
  };

  useEffect(() => {
    performSearch().then((results) => {
      console.log(results);
    });
  }, [performSearch]); // ⚠️ performSearch змінюється при кожному рендері!
}

Правильне рішення:

Винесіть функцію за межі компонента або створіть всередині ефекту:

// Варіант 1: функція поза компонентом
function performSearch(query: string) {
  return fetch(`/api/search?q=${query}`).then((r) => r.json());
}

function SearchResults({ query }: { query: string }) {
  useEffect(() => {
    performSearch(query).then((results) => {
      console.log(results);
    });
  }, [query]); // performSearch стабільна, query — єдина залежність
}

// Варіант 2: інлайн всередині ефекту
function SearchResults({ query }: { query: string }) {
  useEffect(() => {
    const performSearch = () => {
      return fetch(`/api/search?q=${query}`).then((r) => r.json());
    };
    
    performSearch().then((results) => {
      console.log(results);
    });
  }, [query]);
}

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


Діаграма прийняття рішень: як усунути залежність

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

start

:Лінтер скаржиться<br/>на пропущену залежність;

if (Значення статичне<br/>(не використовує<br/>пропси/стан)?) then (так)
  :Винести за межі<br/>компонента;
  stop
else (ні)
endif

if (Значення потрібне<br/>лише цьому ефекту?) then (так)
  :Перемістити всередину<br/>ефекту;
  stop
else (ні)
endif

if (Ефект читає стан<br/>для обчислення<br/>наступного?) then (так)
  :Використати<br/>функціональне оновлення<br/>setState(prev => ...);
  stop
else (ні)
endif

if (Ефект синхронізує<br/>кілька незалежних<br/>систем?) then (так)
  :Розділити на окремі<br/>ефекти;
  stop
else (ні)
endif

if (Ефект потребує<br/>свіжих даних без<br/>реактивності?) then (так)
  :Застосувати патерн рефи<br/>(лекція 30);
  stop
else (ні)
endif

:Додати залежність<br/>до масиву;

stop

@enduml

Інтерактивна демонстрація: таймер із динамічним інтервалом

React Оптимізація залежностей таймера
Файли проєкту
src
components
OptimizedTimer.tsx
UnoptimizedTimer.tsx
App.tsx
styles.css
localhost:3000

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

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

  1. Неоптимізована версія (count та interval у залежностях):
    • Консоль показує постійні повідомлення ❌ Таймер створено / 🗑️ Таймер очищено.
    • При кожному тіку лічильника або зміні слайдера таймер перестворюється.
  2. Оптимізована версія (функціональне оновлення + реф для interval):
    • Консоль показує ✅ Таймер створено лише один раз при монтуванні.
    • Зміна слайдера не перестворює таймер — intervalRef.current оновлюється синхронно.
    • Лічильник продовжує працювати без переривань.

Поглиблене розуміння: чому React вимагає exhaustive dependencies

Правило exhaustive-deps існує не для того, щоб дратувати розробників, а щоб запобігти застарілим замиканням (stale closures) — однієї з найпідступніших категорій багів у React.

Що таке застаріле замикання?

Замикання (closure) — це механізм JavaScript, за яким функція «запам'ятовує» змінні з зовнішньої області видимості, де вона була оголошена. У React кожен рендер створює новий снапшот усіх змінних (пропсів, стану, обчислених значень), і функції, оголошені в цьому рендері, замикаються над цим конкретним снапшотом.

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

  useEffect(() => {
    // Ця функція замикається над count = 0 (перший рендер)
    const id = setInterval(() => {
      console.log("Поточний count:", count); // Завжди виводить 0!
      setCount(count + 1); // Завжди встановлює 0 + 1 = 1
    }, 1000);

    return () => clearInterval(id);
  }, []); // count не у залежностях — застаріле замикання!

  return <h1>{count}</h1>;
}

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

  1. При першому рендері count = 0, ефект створює функцію всередині setInterval, яка замикається над count = 0.
  2. Функція запускається кожну секунду і виконує setCount(0 + 1), встановлюючи count у 1.
  3. React перерендерює компонент із count = 1, але ефект не перезапускається (порожній масив залежностей).
  4. Таймер продовжує виконувати ту саму функцію із замиканням над count = 0.
  5. Результат: лічильник завжди показує 1, незалежно від кількості секунд.
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

participant "React" as React
participant "Ефект (Render #1)" as Effect1
participant "setInterval callback" as Callback1

React -> Effect1 : Рендер #1 (count = 0)
activate Effect1
Effect1 -> Callback1 : Створює функцію\nзі замиканням\ncount = 0
activate Callback1
Effect1 --> React : Таймер запущено

note over Callback1 #FEE2E2
  Замикання над count = 0
  Ця функція "заморожена"
end note

Callback1 -> React : t=1s: setCount(0 + 1)
React -> React : Рендер #2 (count = 1)
note right
  Новий рендер з count = 1,
  але ефект НЕ запускається!
  Callback1 продовжує працювати
  зі старим count = 0
end note

Callback1 -> React : t=2s: setCount(0 + 1)
React -> React : Рендер #3 (count = 1)

Callback1 -> React : t=3s: setCount(0 + 1)
React -> React : Рендер #4 (count = 1)

note over Callback1,React #FEE2E2
  Лічильник назавжди застряг на 1,
  бо callback читає застаріле
  значення count = 0
end note

deactivate Callback1
deactivate Effect1

@enduml

Як exhaustive-deps запобігає цьому

Якщо додати count до залежностей:

useEffect(() => {
  const id = setInterval(() => {
    console.log("Поточний count:", count);
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(id);
}, [count]); // ✅ count у залежностях

React буде перезапускати ефект щоразу, коли змінюється count, створюючи нову функцію callback із свіжим замиканням. Але це призведе до постійного перестворення таймера (як ми бачили раніше) — тому правильне рішення — функціональне оновлення.


Рецепти для типових сценаріїв

Рецепт 1: Завантаження даних з AbortController

Проблема: При швидкій зміні параметра запиту (користувач швидко вводить у пошук) старі запити продовжують виконуватися і можуть оновити стан після нових запитів (race condition).

Рішення: Використання AbortController для скасування попередніх запитів.

function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    // Створюємо AbortController всередині ефекту
    const controller = new AbortController();
    const { signal } = controller;

    setIsLoading(true);

    fetch(`/api/search?q=${query}`, { signal })
      .then((response) => response.json())
      .then((data) => {
        setResults(data);
        setIsLoading(false);
      })
      .catch((error) => {
        if (error.name !== 'AbortError') {
          console.error('Помилка завантаження:', error);
          setIsLoading(false);
        }
        // AbortError ігноруємо — це нормальне скасування
      });

    return () => {
      // При перезапуску ефекту скасовуємо попередній запит
      controller.abort();
    };
  }, [query]); // Залежність від query

  if (isLoading) return <div>Завантаження...</div>;

  return (
    <ul>
      {results.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
}
Чому це важливо:Якщо користувач вводить "react" → "react hooks" → "react hooks tutorial" за 300мс, без AbortController усі три запити виконаються паралельно. Результати першого запиту ("react") можуть прийти після третього ("react hooks tutorial") і перезаписати правильні дані старими. AbortController гарантує, що лише останній запит оновить стан.

Рецепт 2: Підписка на події клавіатури з динамічним обробником

Проблема: Обробник події клавіатури потребує доступу до поточного стану, але зміна стану не повинна відписувати та повторно підписувати listener.

Рішення: Патерн рефи для нереактивного доступу до стану.

function KeyboardShortcuts() {
  const [mode, setMode] = useState<'normal' | 'insert'>('normal');
  const [log, setLog] = useState<string[]>([]);

  const modeRef = useRef(mode);
  modeRef.current = mode;

  useEffect(() => {
    function handleKeyDown(event: KeyboardEvent) {
      // Читаємо актуальний режим з рефи
      const currentMode = modeRef.current;

      if (event.key === 'i' && currentMode === 'normal') {
        setMode('insert');
        setLog((prev) => [...prev, 'Перехід у режим вставки']);
      } else if (event.key === 'Escape' && currentMode === 'insert') {
        setMode('normal');
        setLog((prev) => [...prev, 'Перехід у звичайний режим']);
      }
    }

    window.addEventListener('keydown', handleKeyDown);

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, []); // Listener створюється один раз!

  return (
    <div>
      <h2>Режим: {mode}</h2>
      <ul>
        {log.map((entry, i) => (
          <li key={i}>{entry}</li>
        ))}
      </ul>
    </div>
  );
}

Рецепт 3: Синхронізація з localStorage без зайвих записів

Проблема: При кожному рендері (навіть якщо стан не змінився) ефект записує у localStorage.

Рішення: Порівняння попереднього значення через рефу або виклик setter лише при реальній зміні.

function usePersistentState<T>(key: string, initialValue: T) {
  const [state, setState] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });

  const prevStateRef = useRef<T>(state);

  useEffect(() => {
    // Записуємо у localStorage лише якщо стан реально змінився
    if (prevStateRef.current !== state) {
      localStorage.setItem(key, JSON.stringify(state));
      prevStateRef.current = state;
      console.log(`✅ Збережено у localStorage: ${key}`);
    }
  }, [key, state]);

  return [state, setState] as const;
}

// Використання
function Settings() {
  const [theme, setTheme] = usePersistentState<'light' | 'dark'>('theme', 'light');

  return (
    <div>
      <button onClick={() => setTheme('light')}>Світла</button>
      <button onClick={() => setTheme('dark')}>Темна</button>
      <p>Поточна тема: {theme}</p>
    </div>
  );
}
Реф prevStateRef запам'ятовує попереднє значення стану. Якщо state не змінився (користувач натиснув ту саму кнопку двічі), запис у localStorage не відбувається. Це економить ресурси та запобігає зайвим викликам синхронного API браузера.

Чеклист перед відправкою коду на code review

1. Перевірте, чи всі реактивні значення у залежностях

Запустіть лінтер ESLint із правилом react-hooks/exhaustive-deps. Якщо є попередження — не ігноруйте його, а застосуйте одну з чотирьох стратегій.

2. Переконайтеся, що функції очищення коректні

Кожен ефект, що створює підписку, таймер, з'єднання або listener, має повертати функцію очищення. Інакше виникне витік пам'яті (memory leak).

3. Уникайте зайвих залежностей від об'єктів

Якщо об'єкт створюється всередині компонента і передається в ефект — створіть його всередині ефекту або деструктуризуйте на примітиви.

4. Застосуйте функціональне оновлення для стану

Якщо ефект читає стан лише для обчислення наступного значення — використайте setState(prev => ...).

5. Розділіть складні ефекти на прості

Один ефект = одна синхронізація з зовнішньою системою. Не намагайтеся «оптимізувати», об'єднуючи логічно незалежні синхронізації в один ефект.


Резюме: чотири стратегії усунення залежностей

1️⃣ Винесення статичного коду

Якщо об'єкт, функція або константа не залежать від пропсів чи стану — оголосіть їх поза компонентом. Посилання стане стабільним, залежність не потрібна.

Приклад: const API_URL = "..."; поза компонентом.

2️⃣ Переміщення всередину ефекту

Якщо об'єкт або функція потрібні лише цьому ефекту — створіть їх всередині useEffect. Залежність від об'єкта зникне, залишиться залежність від примітивів.

Приклад: const options = { roomId }; всередині ефекту.

3️⃣ Функціональне оновлення стану

Якщо ефект читає стан лише для обчислення наступного — передайте функцію setState(prev => ...). Залежність від стану зникне повністю.

Приклад: setCount(prev => prev + 1).

4️⃣ Розділення ефектів

Якщо один ефект синхронізує кілька незалежних систем — розділіть його на окремі useEffect, кожен зі своїми точковими залежностями.

Приклад: Окремі ефекти для WebSocket, аналітики, завантаження даних.


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

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

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

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

У наступній лекції ми вивчимо кастомні хуки — як витягувати повторювану реактивну логіку (поєднання стану, рефів та ефектів) в окремі функції для повторного використання між компонентами. Ви дізнаєтесь, як створювати власні useFetch, useOnlineStatus, useLocalStorage та інші корисні абстракції.
Copyright © 2026