Пошук уроків, статей та іншого контенту
Розбираємось, навіщо React 18 у StrictMode подвійно викликає ефекти — і чому це не баг.
У React 18 під час розробки компонент, огорнутий у StrictMode, може виконати ефект двічі:
React монтує компонент.
Виконує функцію, передану в useEffect.
Виконує функцію очищення.
Імітує повторне монтування компонента.
Знову виконує useEffect.
Спрощено послідовність виглядає так:
mount
setup effect
cleanup effect
setup effect againЦя поведінка стосується режиму розробки. У продакшен-збірці React не виконує це додаткове повторне проходження.
Розглянемо простий приклад:
import { useEffect } from "react";
function Example() {
useEffect(() => {
console.log("Ефект виконано");
return () => {
console.log("Очищення ефекту");
};
}, []);
return <div>Приклад</div>;
}У StrictMode під час розробки в консолі можна побачити:
Ефект виконано
Очищення ефекту
Ефект виконаноНа перший погляд це схоже на помилку. Насправді React навмисно перевіряє, чи коректно компонент працює після монтування, очищення та повторного монтування.
StrictMode — це спеціальний компонент React, який допомагає знаходити потенційні проблеми під час розробки.
Наприклад:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);StrictMode не додає функціональності у продакшен і не впливає на інтерфейс користувача безпосередньо. Його завдання — зробити проблеми видимими раніше.
Серед іншого, він допомагає виявити:
ефекти без коректного очищення;
побічні ефекти під час рендерингу;
залежність від того, що компонент монтується лише один раз;
код, який не готовий до повторного монтування;
використання застарілих підходів React.
Головна причина — React хоче переконатися, що ефект можна безпечно запустити, зупинити та запустити знову.
Компонент може бути змонтований повторно не лише через StrictMode. У реальних застосунках це може трапитися через:
зміну маршруту;
умовний рендеринг;
перемикання вкладок;
роботу бібліотек маршрутизації;
механізми приховування та повторного показу UI;
майбутні оптимізації React.
Тому ефект має поводитися як незалежна операція:
під час запуску він створює підписку, таймер або з’єднання;
під час очищення видаляє їх;
повторний запуск не створює дублікати.
StrictMode фактично ставить компоненту запитання:
Чи можна безпечно знищити цей компонент і створити його знову?
Якщо відповідь «ні», проблема зазвичай не в самому React, а у відсутньому або неправильному очищенні побічного ефекту.
Функція, передана в useEffect, може повернути функцію очищення:
useEffect(() => {
// запуск ефекту
return () => {
// очищення ефекту
};
}, []);Наприклад, підписка на подію:
import { useEffect } from "react";
function WindowWidthLogger() {
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <p>Змініть розмір вікна</p>;
}Без очищення після повторного монтування може залишитися стара підписка. У результаті один клік або одна подія оброблятиметься кілька разів.
Правильний ефект завжди має симетричну структуру:
додати слухач → видалити слухач
запустити таймер → зупинити таймер
підписатися → відписатися
відкрити з’єднання → закрити з’єднання
створити ресурс → звільнити ресурсНеправильний варіант:
import { useEffect } from "react";
function Timer() {
useEffect(() => {
setInterval(() => {
console.log("Тік");
}, 1000);
}, []);
return <p>Таймер запущено</p>;
}Цей ефект створює інтервал, але ніколи його не зупиняє. У StrictMode можна швидко помітити проблему: після повторного запуску компонента працюватиме більше одного інтервалу.
Правильний варіант:
import { useEffect } from "react";
function Timer() {
useEffect(() => {
const intervalId = setInterval(() => {
console.log("Тік");
}, 1000);
return () => {
clearInterval(intervalId);
};
}, []);
return <p>Таймер запущено</p>;
}Тепер кожен запущений інтервал має відповідне очищення.
Ефекти часто використовують для отримання даних. Якщо параметри запиту змінюються, попередній запит може стати непотрібним.
Для цього можна використовувати AbortController:
import { useEffect, useState } from "react";
function User({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Не вдалося завантажити користувача", error);
}
}
}
loadUser();
return () => {
controller.abort();
};
}, [userId]);
if (!user) {
return <p>Завантаження...</p>;
}
return <p>{user.name}</p>;
}У розробці React може запустити цей ефект, скасувати запит і запустити його знову. Це допомагає перевірити, чи правильно код обробляє скасування асинхронних операцій.
Важливо: скасування запиту не означає, що сервер обов’язково «забув» про нього. Воно скасовує очікування відповіді на стороні браузера та допомагає не оновлювати стан неактуальними даними.
useEffect(() => {}, []) не означає «один раз назавжди»Порожній масив залежностей означає:
Запускай цей ефект після монтування конкретного екземпляра компонента і не запускай його повторно через оновлення його стану чи пропсів.
Це не означає, що ефект ніколи більше не виконається за будь-яких обставин.
Компонент може бути:
розмонтований;
змонтований знову;
перевірений через StrictMode;
замінений іншим екземпляром.
Тому навіть ефект із порожнім масивом повинен мати коректне очищення.
Додатковий цикл setup → cleanup → setup для ефектів є поведінкою режиму розробки в StrictMode.
У продакшен-збірці React не виконує цю спеціальну перевірку. Проте це не означає, що проблему можна ігнорувати.
Якщо ефект створює дублікати, не звільняє ресурси або працює лише за умови одноразового монтування, він може зламатися і в продакшені під час звичайного повторного монтування компонента.
Отже, StrictMode не створює помилку. Він показує помилку, яка вже є в логіці компонента.
У StrictMode React також може викликати функцію компонента більше одного разу під час розробки, щоб виявити побічні ефекти в процесі рендерингу.
Рендер має бути чистим:
function Product({ price }) {
const formattedPrice = price.toFixed(2);
return <p>{formattedPrice}</p>;
}Не варто змінювати зовнішні дані безпосередньо в тілі компонента:
function ProductList({ products }) {
products.sort((a, b) => a.price - b.price);
return products.map((product) => (
<p key={product.id}>{product.name}</p>
));
}Метод sort() змінює початковий масив. Якщо компонент буде викликаний повторно, це може призвести до неочікуваної поведінки.
Безпечніший варіант:
function ProductList({ products }) {
const sortedProducts = [...products].sort(
(a, b) => a.price - b.price
);
return sortedProducts.map((product) => (
<p key={product.id}>{product.name}</p>
));
}Побічні ефекти, як-от підписки, запити та робота з DOM, мають бути в useEffect або іншому відповідному механізмі, а не безпосередньо під час рендерингу.
Зазвичай — ні.
Тимчасове видалення StrictMode може приховати повідомлення в консолі, але не виправить причину проблеми. Краще перевірити:
чи є функція очищення;
чи очищає вона саме той ресурс, який створив ефект;
чи не створюються дублікати підписок;
чи не запускається таймер без clearInterval або clearTimeout;
чи не оновлюється стан після скасування неактуального запиту;
чи всі зовнішні значення вказані в масиві залежностей.
StrictMode можна тимчасово вимкнути для діагностики, але в розробці корисно залишати його увімкненим.
Іноді потрібно виконати одноразову ініціалізацію, наприклад завантажити сторонній скрипт або налаштувати глобальний сервіс. У такому випадку потрібно спочатку з’ясувати, кому належить цей ресурс і хто відповідає за його життєвий цикл.
Поганий підхід — приховати повторний запуск за допомогою прапорця:
const hasRun = useRef(false);
useEffect(() => {
if (hasRun.current) {
return;
}
hasRun.current = true;
// одноразова дія
}, []);Такий код може прибрати повідомлення в консолі, але не вирішує проблему очищення. Крім того, він може поводитися неправильно, якщо зміняться залежності або компонент буде використано в іншому сценарії.
Потрібно розрізняти два випадки:
ефект має виконуватися для кожного життєвого циклу компонента — тоді потрібні правильні setup і cleanup;
ресурс справді глобальний і має створюватися один раз для всього застосунку — тоді його краще винести на рівень застосунку або в окремий модуль із чітким керуванням життєвим циклом.
Якщо ефект виконується двічі, додайте лог і до запуску, і до очищення:
useEffect(() => {
console.log("Створення підписки");
return () => {
console.log("Видалення підписки");
};
}, []);Очікувана послідовність у StrictMode:
Створення підписки
Видалення підписки
Створення підпискиЯкщо очищення не з’являється, ефект, імовірно, не повертає функцію очищення.
Якщо після повторного запуску залишається кілька активних підписок або таймерів, cleanup працює неправильно або очищає не той ресурс.
useEffect(() => {
window.addEventListener("scroll", handleScroll);
}, []);Потрібно видалити саме ту функцію, яку було передано під час підписки:
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);Це не працює:
useEffect(() => {
window.addEventListener("scroll", () => {
console.log("Прокручування");
});
return () => {
window.removeEventListener("scroll", () => {
console.log("Прокручування");
});
};
}, []);Функції-обробники створюються окремо, тому React або браузер не вважає їх тією самою функцією.
Потрібно зберегти обробник у змінній:
useEffect(() => {
const handleScroll = () => {
console.log("Прокручування");
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);useEffect для обчисленьНе потрібно використовувати ефект для простого обчислення похідного значення:
function FullName({ firstName, lastName }) {
const fullName = `${firstName} ${lastName}`;
return <p>{fullName}</p>;
}Зайвий ефект додає ще один цикл оновлення:
function FullName({ firstName, lastName }) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <p>{fullName}</p>;
}useEffect потрібен для синхронізації із зовнішніми системами, а не для кожного перетворення даних.
Якщо ефект використовує пропс або значення зі стану, їх зазвичай потрібно вказати в масиві залежностей:
useEffect(() => {
document.title = title;
}, [title]);Порожній масив у такому випадку може призвести до використання застарілого значення.
useRefuseRef не повинен використовуватися лише для того, щоб «пережити» подвійний запуск ефекту. Спочатку потрібно зробити ефект ідемпотентним та додати правильне очищення.
Перевірте ефект за такими питаннями:
Що саме створює цей ефект?
Чи має створений ресурс функцію очищення?
Чи виконує cleanup повну зворотну операцію?
Чи можна безпечно виконати setup, потім cleanup, а потім знову setup?
Чи вказані всі потрібні залежності?
Чи не виконується побічна дія безпосередньо під час рендерингу?
Чи не потрібен тут взагалі useEffect?
Чи коректно обробляється скасування асинхронної операції?
Подвійне виконання useEffect у React 18 під час розробки — це перевірка, яку виконує StrictMode, а не баг фреймворку.
React імітує послідовність:
монтування → запуск ефекту → очищення → повторний запускЦе допомагає знайти:
відсутні очищення;
дублікати підписок і таймерів;
побічні ефекти під час рендерингу;
залежність від одноразового монтування;
проблеми в асинхронному коді.
Правильний useEffect має коректно працювати після повторного запуску. Якщо ефект створює ресурс, він повинен також звільняти цей ресурс. Саме така модель робить компоненти передбачуваними, стійкими до повторного монтування та готовими до сучасного способу роботи React.