Видалення зайвих залежностей ефектів та оптимізація синхронізації
Видалення зайвих залежностей ефектів та оптимізація синхронізації
🔗 Офіційний референс: Видалення залежностей ефектів (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>;
}
Що відбувається:
- При кожному тіку таймера (кожну секунду)
setCount(count + 1)оновлює стан. - React перерендерює компонент із новим значенням
count. - Ефект бачить зміну залежності
countі перестворює таймер (очищає старийsetInterval, створює новий). - Таймер ніколи не працює більше однієї секунди — кожну секунду він створюється заново.
Це не тільки неефективно, а й потенційно небезпечно: якщо очищення має побічні ефекти (наприклад, розрив 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>;
}
Що відбувається тепер:
- Ефект запускається один раз при монтуванні компонента (порожній масив залежностей
[]). - Таймер створюється один раз і більше ніколи не очищується до розмонтування компонента.
- Кожну секунду викликається
setCount((prevCount) => prevCount + 1). - React автоматично передає актуальне значення
countу параметрprevCountфункції-оновлювача. - Замикання не зберігає застаріле значення
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>;
}
Проблема:
Коли користувач просто перемикає тему інтерфейсу (світла/темна), ефект:
- Відписується від оновлень профілю.
- Повторно завантажує дані користувача з сервера (навіть якщо
userIdне змінився). - Повторно відправляє подію аналітики.
- Повторно підписується на оновлення профілю.
Це марнує ресурси та може призвести до блимання інтерфейсу.
Рішення: три окремих ефекти з точковими залежностями
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]);
}
Практичні завдання для закріплення
Наступний компонент містить помилку — таймер перестворюється при кожному оновленні count або step. Оптимізуйте код так, щоб таймер створювався лише один раз при монтуванні компонента.
Початковий код із проблемою:
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
useEffect(() => {
const id = setInterval(() => {
setCount(count + step); // ❌ Читаємо count та step
}, 1000);
return () => clearInterval(id);
}, [count, step]); // ⚠️ Таймер перестворюється щосекунди!
return (
<div>
<h1>Лічильник: {count}</h1>
<button onClick={() => setStep(step + 1)}>
Збільшити крок (зараз: {step})
</button>
</div>
);
}
Очікувана поведінка:
- Таймер створюється один раз при монтуванні.
- Лічильник збільшується на поточне значення
stepкожну секунду. - Зміна
stepчерез кнопку відразу впливає на наступний тік таймера (без перестворення таймера).
Правильна реалізація через рефу та функціональне оновлення:
import { useState, useEffect, useRef } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
// Зберігаємо актуальний step у рефі
const stepRef = useRef(step);
stepRef.current = step;
useEffect(() => {
const id = setInterval(() => {
// Функціональне оновлення з читанням stepRef
setCount((prevCount) => prevCount + stepRef.current);
}, 1000);
return () => clearInterval(id);
}, []); // Порожній масив — таймер створюється один раз!
return (
<div>
<h1>Лічильник: {count}</h1>
<button onClick={() => setStep(step + 1)}>
Збільшити крок (зараз: {step})
</button>
</div>
);
}
Покрокове пояснення:
const stepRef = useRef(step)— створюємо рефу для збереження актуального кроку.stepRef.current = step— синхронно оновлюємо рефу при кожному рендері. Якщо користувач натисне кнопку іstepзміниться на2, реф негайно оновиться до2.setCount((prevCount) => prevCount + stepRef.current)— функціональне оновлення стану:prevCount— актуальне значенняcount, яке React передає автоматично.stepRef.current— актуальне значення кроку, яке ми читаємо з рефи.- Замикання не зберігає застарілі значення — кожен тік таймера читає свіжі дані.
}, [])— порожній масив залежностей означає, що ефект виконується один раз при монтуванні. Таймер більше ніколи не перестворюється.
Чому це працює:
- Зміна
stepоновлюєstepRef.current, але не перезапускає ефект (боstepне у залежностях). - Зміна
countтакож не перезапускає ефект (бо ми використовуємо функціональне оновлення). - Таймер працює безперервно від монтування до розмонтування компонента.
Компонент форми реєстрації містить один великий ефект, що синхронізує три незалежних системи. Розділіть його на три окремих ефекти з точковими залежностями.
Початковий код із проблемою:
interface RegistrationFormProps {
country: string;
email: string;
marketingConsent: boolean;
}
function RegistrationForm({
country,
email,
marketingConsent,
}: RegistrationFormProps) {
useEffect(() => {
// Система 1: завантаження списку міст країни
fetchCitiesByCountry(country).then((cities) => {
console.log("Міста завантажені:", cities);
});
// Система 2: перевірка унікальності email
checkEmailAvailability(email).then((isAvailable) => {
console.log("Email доступний:", isAvailable);
});
// Система 3: підписка на розсилку
if (marketingConsent) {
const unsubscribe = subscribeToNewsletter(email);
return () => unsubscribe();
}
}, [country, email, marketingConsent]); // ⚠️ Зміна будь-чого перезапускає все!
return <div>Форма реєстрації</div>;
}
Проблема:
- Зміна
marketingConsent(користувач поставив/зняв галочку) перезавантажує список міст та перевіряє email. - Зміна
country(вибір іншої країни) повторно перевіряє email та керує підпискою. - Зміна
emailповторно завантажує міста.
Правильна реалізація через три незалежних ефекти:
function RegistrationForm({
country,
email,
marketingConsent,
}: RegistrationFormProps) {
// Ефект 1: завантаження міст (залежить тільки від country)
useEffect(() => {
fetchCitiesByCountry(country).then((cities) => {
console.log("Міста завантажені:", cities);
});
}, [country]);
// Ефект 2: перевірка email (залежить тільки від email)
useEffect(() => {
checkEmailAvailability(email).then((isAvailable) => {
console.log("Email доступний:", isAvailable);
});
}, [email]);
// Ефект 3: підписка на розсилку (залежить від email та marketingConsent)
useEffect(() => {
if (marketingConsent) {
const unsubscribe = subscribeToNewsletter(email);
return () => unsubscribe();
}
}, [email, marketingConsent]);
return <div>Форма реєстрації</div>;
}
Покрокове пояснення:
- Ефект 1 синхронізує список міст з вибраною країною. Він запускається виключно при зміні
country. ЗмінаemailабоmarketingConsentне впливає на завантаження міст. - Ефект 2 перевіряє доступність email на сервері. Він запускається виключно при зміні
email. ЗмінаcountryабоmarketingConsentне викликає повторну перевірку. - Ефект 3 керує підпискою на розсилку. Він запускається при зміні або
email(потрібно відписатися зі старого email та підписатися на новий), абоmarketingConsent(користувач поставив/зняв галочку). Умоваif (marketingConsent)гарантує, що підписка відбувається лише за згоди користувача.
Переваги:
- Кожен ефект має мінімальний набір залежностей — лише ті, що реально впливають на його синхронізацію.
- Зміна однієї змінної не викликає зайвих побічних ефектів у незалежних системах.
- Код стає читабельнішим — одна відповідальність на один ефект.
Компонент списку товарів приймає об'єкт фільтрів через пропси. Лінтер вимагає додати filters до залежностей, але це призводить до зайвих запитів. Оптимізуйте код.
Початковий код із проблемою:
interface Filters {
category: string;
minPrice: number;
maxPrice: number;
}
interface ProductListProps {
filters: Filters;
}
function ProductList({ filters }: ProductListProps) {
const [products, setProducts] = useState([]);
useEffect(() => {
fetchProducts(filters).then((data) => {
setProducts(data);
});
}, [filters]); // ⚠️ filters — новий об'єкт при кожному рендері батьківського компонента!
return <ul>{/* ... */}</ul>;
}
Проблема:
Батьківський компонент може створювати новий об'єкт filters при кожному рендері, навіть якщо значення всередині не змінилися. Це призводить до повторних запитів до API.
Варіант 1: Деструктуризація пропсів та залежність від примітивів
function ProductList({ filters }: ProductListProps) {
const [products, setProducts] = useState([]);
// Деструктуризуємо об'єкт filters на окремі примітиви
const { category, minPrice, maxPrice } = filters;
useEffect(() => {
// Створюємо об'єкт filters всередині ефекту
const filterParams = { category, minPrice, maxPrice };
fetchProducts(filterParams).then((data) => {
setProducts(data);
});
}, [category, minPrice, maxPrice]); // Залежність від примітивів!
return <ul>{/* ... */}</ul>;
}
Варіант 2: Винесення API-функції та передача окремих параметрів
// Поза компонентом
async function fetchProducts(
category: string,
minPrice: number,
maxPrice: number
) {
const response = await fetch(
`/api/products?category=${category}&minPrice=${minPrice}&maxPrice=${maxPrice}`
);
return response.json();
}
function ProductList({ filters }: ProductListProps) {
const [products, setProducts] = useState([]);
const { category, minPrice, maxPrice } = filters;
useEffect(() => {
fetchProducts(category, minPrice, maxPrice).then((data) => {
setProducts(data);
});
}, [category, minPrice, maxPrice]);
return <ul>{/* ... */}</ul>;
}
Пояснення:
- Деструктуризація
{ category, minPrice, maxPrice } = filtersвитягує примітивні значення з об'єкта. - Примітиви порівнюються за значенням (
===), а не за посиланням у пам'яті. - Ефект запускається лише тоді, коли реально змінюється одне з цих значень, навіть якщо батьківський компонент передає новий об'єкт
filters.
Діаграма прийняття рішень: як усунути залежність
Інтерактивна демонстрація: таймер із динамічним інтервалом
Збирання React застосунку…
Що демонструє цей приклад:
- Неоптимізована версія (
countтаintervalу залежностях):- Консоль показує постійні повідомлення
❌ Таймер створено/🗑️ Таймер очищено. - При кожному тіку лічильника або зміні слайдера таймер перестворюється.
- Консоль показує постійні повідомлення
- Оптимізована версія (функціональне оновлення + реф для
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>;
}
Що відбувається:
- При першому рендері
count = 0, ефект створює функцію всерединіsetInterval, яка замикається надcount = 0. - Функція запускається кожну секунду і виконує
setCount(0 + 1), встановлюючиcountу1. - React перерендерює компонент із
count = 1, але ефект не перезапускається (порожній масив залежностей). - Таймер продовжує виконувати ту саму функцію із замиканням над
count = 0. - Результат: лічильник завжди показує
1, незалежно від кількості секунд.
Як 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 та інші корисні абстракції.Розділення подій та ефектів: реактивна проти нереактивної логіки
Глибоке розуміння різниці між обробниками подій та ефектами: коли код має бути реактивним, а коли ні. Практичні патерни збереження нереактивної логіки через рефи та майбутній useEffectEvent.
Кастомні хуки: повторне використання та композиція реактивної логіки
Створення власних хуків для витягування повторюваної логіки: useOnlineStatus, useFetch, useLocalStorage, useDebounce. Правила композиції хуків, типізація TypeScript та архітектурні патерни для масштабованих застосунків.