Життєвий цикл реактивних ефектів: мислення процесами синхронізації
Життєвий цикл реактивних ефектів: мислення процесами синхронізації
🔗 Офіційний референс: Життєвий цикл реактивних ефектів (Lifecycle of Reactive Effects)
🎯 Мета лекції
- Зрозуміти різницю між життєвим циклом компонента та життєвим циклом ефекту.
- Опанувати концепцію початку та кінця синхронізації замість монтування/розмонтування.
- Навчитися визначати реактивні значення та правильно вказувати залежності.
- Застосувати правило
exhaustive-depsдля запобігання застарілим замиканням.
🔑 Ключові терміни
- Реактивне значення (Reactive Value): будь-яка змінна, оголошена всередині компонента, що може змінюватися між рендерами.
- Застаріле замикання (Stale Closure): ситуація, коли функція всередині ефекту читає застаріле значення реактивної змінної.
- Exhaustive Dependencies: правило лінтера, що вимагає включення всіх реактивних значень у масив залежностей.
- Повторна синхронізація (Re-synchronization): процес зупинки попереднього ефекту та запуску нового з актуальними даними.
Компоненти мають життєвий цикл, але ефекти мають інший
У попередніх лекціях ми вивчили, що React-компоненти проходять через три фази:
- Монтування (Mount): компонент з'являється на екрані вперше.
- Оновлення (Update): компонент перерендерюється через зміну пропсів або стану.
- Розмонтування (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":
- React рендерить компонент із новим
roomId: "travel". - React бачить, що значення
roomIdу масиві залежностей змінилося. - React спочатку викликає функцію очищення попереднього ефекту із старим
roomId: "general":❌ Відключення від кімнати "general" - React потім виконує новий ефект із актуальним
roomId: "travel":✅ Підключення до кімнати "travel"
Збирання React застосунку…
Ключовий момент: React не пам'ятає, скільки разів компонент рендерився. React бачить лише актуальні значення залежностей і порівнює їх із попередніми через Object.is(). Якщо хоча б одна залежність змінилася, React виконує цикл cleanup → setup знову.
❌ Відключення від кімнати "general"
✅ Підключення до кімнати "travel"
❌ Відключення від кімнати "travel"
✅ Підключення до кімнати "music"
Реактивні значення: що повинно бути у залежностях
Реактивне значення (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>
);
}
Що тут не так:
- Ефект запускається один раз при монтуванні, коли
count === 0. - Колбек
setIntervalзахоплює значенняcountу замиканні — воно «заморожується» на0. - Навіть коли користувач збільшує лічильник до
5, інтервал продовжує виводитиПоточний лічильник: 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 аналізує код ефекту та видає попередження, якщо:
- Забута залежність: Ви читаєте реактивне значення всередині ефекту, але не додали його до масиву залежностей.
- Зайва залежність: Ви додали у залежності значення, яке не є реактивним (константу поза компонентом).
- Нестабільна залежність: Ви додали об'єкт або функцію, що створюється при кожному рендері.
Приклад попередження лінтера
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.
Два варіанти виправлення:
- Додати
messageу залежності (якщо ефект справді повинен перезапускатися при зміні повідомлення):useEffect(() => { const connection = connectToChat(roomId); connection.send(message); return () => connection.close(); }, [roomId, message]); - Перемістити логіку в обробник події (якщо відправка повинна відбуватися лише при натисканні кнопки, а не при кожній зміні):
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 без додавання його до залежностей ефекту.
Практичні завдання
Наведений компонент містить баг застарілого замикання. Кнопка «Показати затримане повідомлення» показує значення name через 3 секунди, але завжди виводить застаріле значення, навіть якщо користувач змінив ім'я в інпуті.
Вихідний код:
import { useState } from "react";
export function DelayedGreeting() {
const [name, setName] = useState("Гість");
const handleClick = () => {
setTimeout(() => {
// БАГ: name захоплено у замиканні при натисканні кнопки
alert(`Привіт, ${name}!`);
}, 3000);
};
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введіть ім'я"
/>
<button onClick={handleClick}>
Показати затримане повідомлення (3 сек)
</button>
</div>
);
}
Завдання: Виправте баг так, щоб alert завжди показував актуальне значення name на момент спрацювання таймера, а не на момент натискання кнопки.
Підказка: Використайте useRef для збереження актуального значення name.
import { useState, useRef, useEffect } from "react";
export function DelayedGreetingFixed() {
const [name, setName] = useState("Гість");
const nameRef = useRef(name);
// ✅ Синхронізуємо реф із актуальним значенням після кожного рендеру
useEffect(() => {
nameRef.current = name;
}, [name]);
const handleClick = () => {
setTimeout(() => {
// ✅ Читаємо актуальне значення з рефа
alert(`Привіт, ${nameRef.current}!`);
}, 3000);
};
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введіть ім'я"
/>
<button onClick={handleClick}>
Показати затримане повідомлення (3 сек)
</button>
<p style={{ fontSize: "13px", color: "#64748b" }}>
Натисніть кнопку, потім швидко змініть ім'я — повідомлення покаже нове значення
</p>
</div>
);
}
Покрокове пояснення:
- Створення рефа:
useRef(name)створює об'єкт із властивістю.current, ініціалізованою початковим значеннямname. - Синхронізація рефа:
useEffectоновлюєnameRef.currentпісля кожного рендеру, колиnameзмінюється. - Читання актуального значення: Всередині
setTimeoutми читаємоnameRef.current, що завжди містить найсвіжіше значення. - Без залежностей у таймері: Колбек
setTimeoutне захоплюєnameу замиканні — він лише читає реф, який завжди актуальний.
useEffect:
Можна оновлювати реф безпосередньо в обробнику onChange:const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newName = e.target.value;
setName(newName);
nameRef.current = newName;
};
useEffect є більш декларативним підходом і гарантує синхронізацію навіть якщо name змінюється з інших місць.Наведений компонент має баг — він потрапляє у нескінченний цикл повторних рендерів. Проаналізуйте код та визначте, яка залежність викликає проблему.
Вихідний код:
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
}
export function UserProfile() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch("/api/user/1");
const data: User = await response.json();
setUser(data);
};
fetchUser();
}, [user]); // ⚠️ Тут криється проблема
if (!user) {
return <p>Завантаження...</p>;
}
return (
<div>
<h3>{user.name}</h3>
<p>ID: {user.id}</p>
</div>
);
}
Питання:
- Чому виникає нескінченний цикл?
- Як виправити код?
- Які залежності повинні бути у масиві?
Аналіз проблеми:
- Початковий стан:
user === null. - Перший рендер: React виконує ефект, завантажує дані, викликає
setUser(data). - Другий рендер: React рендерить компонент із новим
user. Значенняuserу залежностях змінилося (null→{id: 1, name: "..."}) → React запускає ефект знову. - Третій рендер: Ефект знову завантажує дані, викликає
setUserз новим об'єктом. Навіть якщо вміст однаковий,Object.is(prevUser, newUser)повернеfalse(різні об'єкти в пам'яті) → React запускає ефект знову. - Нескінченний цикл: Кроки 3-4 повторюються безкінечно.
Правильне рішення:
import { useState, useEffect } from "react";
interface User {
id: number;
name: string;
}
export function UserProfileFixed() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch("/api/user/1");
const data: User = await response.json();
setUser(data);
};
fetchUser();
}, []); // ✅ Порожній масив — завантажуємо дані лише один раз
if (!user) {
return <p>Завантаження...</p>;
}
return (
<div>
<h3>{user.name}</h3>
<p>ID: {user.id}</p>
</div>
);
}
Чому це працює:
- Порожній масив залежностей
[]означає, що ефект виконується лише один раз при монтуванні. - Після завантаження даних та виклику
setUser, компонент рендериться знову, але ефект більше не запускається. - Дані завантажуються лише один раз, що є правильною поведінкою для початкового завантаження профілю.
Коли user повинен бути у залежностях:
Якщо ви хочете перезавантажувати дані при зміні ідентифікатора користувача:
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/user/${userId}`);
const data: User = await response.json();
setUser(data);
};
fetchUser();
}, [userId]); // ✅ userId — примітивне значення, безпечна залежність
// ...
}
Тепер ефект перезапускається лише при зміні userId (наприклад, користувач перейшов до іншого профілю), а не при зміні самого об'єкта user.
Компонент відправляє аналітичну подію на сервер при монтуванні, але потрапляє у нескінченний цикл через нестабільну залежність.
Вихідний код:
import { useEffect } from "react";
interface AnalyticsProps {
userId: number;
page: string;
}
export function AnalyticsTracker({ userId, page }: AnalyticsProps) {
const sendAnalytics = (event: string, data: object) => {
console.log(`📊 Analytics: ${event}`, data);
// У реальності тут був би fetch до сервера
};
useEffect(() => {
sendAnalytics("page_view", { userId, page });
}, [userId, page, sendAnalytics]); // ⚠️ sendAnalytics — нестабільна залежність
return <p>Сторінка: {page}</p>;
}
Завдання: Виправте код так, щоб ефект не потрапляв у нескінченний цикл. Використайте одну з технік:
- Перенесення функції всередину ефекту
- Використання
useCallback - Винесення функції за межі компонента
Рішення 1: Перенесення функції всередину ефекту
import { useEffect } from "react";
interface AnalyticsProps {
userId: number;
page: string;
}
export function AnalyticsTrackerFixed1({ userId, page }: AnalyticsProps) {
useEffect(() => {
// ✅ Функція оголошена всередині ефекту — не потрібна у залежностях
const sendAnalytics = (event: string, data: object) => {
console.log(`📊 Analytics: ${event}`, data);
};
sendAnalytics("page_view", { userId, page });
}, [userId, page]); // Лише userId та page — примітивні значення
return <p>Сторінка: {page}</p>;
}
Рішення 2: Використання useCallback для стабілізації функції
import { useEffect, useCallback } from "react";
interface AnalyticsProps {
userId: number;
page: string;
}
export function AnalyticsTrackerFixed2({ userId, page }: AnalyticsProps) {
// ✅ useCallback створює стабільну версію функції
const sendAnalytics = useCallback((event: string, data: object) => {
console.log(`📊 Analytics: ${event}`, data);
}, []); // Порожній масив — функція ніколи не змінюється
useEffect(() => {
sendAnalytics("page_view", { userId, page });
}, [userId, page, sendAnalytics]); // sendAnalytics тепер стабільна
return <p>Сторінка: {page}</p>;
}
Рішення 3: Винесення функції за межі компонента
import { useEffect } from "react";
interface AnalyticsProps {
userId: number;
page: string;
}
// ✅ Функція поза компонентом — стабільне посилання
function sendAnalytics(event: string, data: object) {
console.log(`📊 Analytics: ${event}`, data);
}
export function AnalyticsTrackerFixed3({ userId, page }: AnalyticsProps) {
useEffect(() => {
sendAnalytics("page_view", { userId, page });
}, [userId, page]); // sendAnalytics не є реактивною — не потрібна у залежностях
return <p>Сторінка: {page}</p>;
}
Яке рішення обрати:
- Рішення 1 (перенесення всередину): Найпростіше, якщо функція потрібна лише у цьому ефекті.
- Рішення 2 (
useCallback): Якщо функція використовується у кількох місцях (інші ефекти, обробники подій, передається як проп). - Рішення 3 (поза компонентом): Якщо функція не потребує доступу до пропсів або стану компонента.
useCallback із порожнім масивом залежностей або техніку рефів (як у завданні 1).Підсумок: ключові принципи життєвого циклу ефектів
🔄 Цикл синхронізації
Ефект не думає про монтування/оновлення/розмонтування. Він вміє лише:
- Почати синхронізацію (setup)
- Припинити синхронізацію (cleanup)
Кількість циклів залежить від змін у залежностях.
⚛️ Реактивні значення
Будь-яка змінна, оголошена всередині компонента, є реактивною:
- Пропси
- Стан
- Локальні змінні
Всі реактивні значення, прочитані в ефекті, обов'язково мають бути у залежностях.
🔍 Правило exhaustive-deps
Лінтер автоматично виявляє:
- Забуті залежності → застарілі замикання
- Нестабільні об'єкти/функції → нескінченні цикли
Ніколи не ігноруйте попередження — виправте код.
🛠️ Техніки оптимізації
Для уникнення зайвих перезапусків:
- Винесіть статичний код за межі компонента
- Використайте
useMemoдля об'єктів - Використайте
useCallbackдля функцій - Використайте
useRefдля нереактивних значень
Наступні кроки
У цій лекції ви опанували фундаментальні принципи життєвого циклу реактивних ефектів: як React порівнює залежності, чому виникають застарілі замикання, та як лінтер exhaustive-deps допомагає уникати багів.
- Наступна лекція: «Розділення подій та ефектів» — розглянемо складнішу проблему: як прочитати свіже значення стану без додавання його до залежностей через техніку рефів та експериментальний хук
useEffectEvent. - Подальші теми: видалення зайвих залежностей ефектів, кастомні хуки для інкапсуляції логіки синхронізації, розширені патерни роботи з асинхронними ефектами.
- Масив залежностей містить об'єкти або функції — можливо, вони створюються при кожному рендері.
- Лінтер видає попередження про пропущені залежності — не ігноруйте їх, виправте через одну з технік цієї лекції.
- Ефекти запускаються частіше, ніж потрібно — проаналізуйте залежності та оптимізуйте через
useMemo/useCallback.
Можливо, вам не потрібен ефект: антипатерни та альтернативи
Глибокий аналіз типових помилок надмірного використання useEffect та правильні альтернативи через обчислювані значення, обробники подій та ключ key. Навчіться писати швидший та простіший код.
Розділення подій та ефектів: реактивна проти нереактивної логіки
Глибоке розуміння різниці між обробниками подій та ефектами: коли код має бути реактивним, а коли ні. Практичні патерни збереження нереактивної логіки через рефи та майбутній useEffectEvent.