React useEffect уходит в бесконечный цикл: как найти причину и исправить
Компонент начинает перерисовываться без остановки, запросы уходят повторно, счётчик сам по себе растёт, а консоль постепенно заполняется одинаковыми сообщениями.
Первым делом обычно смотрят на useEffect.
И это логично. Но сам useEffect далеко не всегда является причиной проблемы. Гораздо полезнее посмотреть на цепочку целиком: что делает эффект, какое состояние он меняет и из-за чего после этого снова меняется его зависимость.
В React бесконечный цикл возникает, когда эффект обновляет состояние, это обновление приводит к новому рендеру, а после этого хотя бы одна зависимость эффекта оказывается другой. React сравнивает зависимости с предыдущими значениями через Object.is, поэтому здесь легко получить неожиданное поведение с объектами и функциями.
Поэтому я бы не начинал исправление с удаления зависимостей. Сначала нужно найти место, где замыкается цикл.
Самый простой пример
Возьмём такой компонент:
//jsx
import { useEffect, useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
return <div>{count}</div>;
}Здесь всё происходит по кругу:
//text
рендер
↓
useEffect
↓
setCount()
↓
новый рендер
↓
useEffect
↓
setCount()
↓
новый рендерУ эффекта нет массива зависимостей, поэтому он запускается после каждого commit. При этом setCount() каждый раз пытается изменить состояние.
Получается бесконечная цепочка.
Но есть ещё более важный вопрос: а нужен ли здесь вообще useEffect?
Иногда проблему нужно решать удалением useEffect
Например:
//jsx
const [firstName, setFirstName] = useState('Stan');
const [lastName, setLastName] = useState('Smith');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);Код работает, но fullName здесь полностью зависит от firstName и lastName.
Хранить его отдельным состоянием необязательно:
//jsx
const [firstName, setFirstName] = useState('Stan');
const [lastName, setLastName] = useState('Smith');
const fullName = `${firstName} ${lastName}`;Теперь нет ни эффекта, ни дополнительного обновления состояния.
Это один из случаев, когда попытка «починить» useEffect приводит не туда. Проблема не в массиве зависимостей. Проблема в том, что эффект изначально использовался для вычисления данных, которые можно получить непосредственно во время рендера.
React отдельно рекомендует не использовать эффект для такого рода вычислений. Эффекты предназначены прежде всего для синхронизации компонента с внешней системой. Если такой системы нет, эффект, скорее всего, вообще не нужен.
Объект в зависимостях может всё испортить
Вот уже более реальная ситуация:
//jsx
function Profile({ userId }) {
const [user, setUser] = useState(null);
const options = {
userId
};
useEffect(() => {
fetchUser(options).then(setUser);
}, [options]);
// ...
}На первый взгляд всё выглядит нормально.
Эффект использует options, поэтому объект добавлен в зависимости.
Проблема появляется из-за этой строки:
//jsx
const options = {
userId
};Она выполняется при каждом рендере.
То есть React получает новый объект:
//jsx
{ userId: 10 }потом ещё один:
//jsx
{ userId: 10 }Визуально объекты одинаковые, но это разные ссылки.
//jsx
Object.is(options1, options2); // falseДля React зависимость изменилась.
Если после выполнения запроса вызывается setUser, получается:
//text
рендер
↓
создали новый options
↓
useEffect
↓
fetch
↓
setUser
↓
новый рендер
↓
создали новый options
↓
зависимость изменилась
↓
useEffectИ цикл повторяется.
React прямо предупреждает, что объекты и функции, созданные внутри компонента, могут заставлять эффект запускаться чаще, чем необходимо.
Что делать с объектом
В такой ситуации я обычно сначала убираю объект из зависимостей, если для него нет самостоятельной необходимости.
Например:
//jsx
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const options = {
userId
};
fetchUser(options).then(setUser);
}, [userId]);
// ...
}Теперь эффект зависит от userId.
Если userId не изменился, новый рендер сам по себе не заставит эффект запускаться снова.
Это не означает, что объекты в зависимостях всегда неправильны. Проблема именно в нестабильной ссылке на объект, который создаётся заново при каждом рендере. Один из рекомендуемых React вариантов - создавать такой объект непосредственно внутри эффекта и оставлять в зависимостях его реальные реактивные значения.
С функциями происходит почти то же самое
Например:
//jsx
function Chat({ roomId }) {
function createOptions() {
return {
roomId
};
}
useEffect(() => {
const options = createOptions();
connect(options);
}, [createOptions]);
// ...
}createOptions создаётся заново при каждом рендере компонента.
Поэтому зависимость:
//jsx
[createOptions]тоже становится новой.
Можно переписать код так:
//jsx
function Chat({ roomId }) {
useEffect(() => {
function createOptions() {
return {
roomId
};
}
const options = createOptions();
connect(options);
}, [roomId]);
// ...
}Теперь эффект зависит от roomId, а не от функции, которая каждый раз создаётся заново.
А может, здесь нужен useCallback?
Иногда useCallback действительно может быть подходящим решением, если функцию необходимо передавать дальше или сохранять её ссылочную стабильность.
Но я бы не начинал с:
//jsx
const createOptions = useCallback(() => {
return {
roomId
};
}, [roomId]);Сначала стоит спросить себя, зачем функция вообще должна находиться за пределами эффекта.
Если она нужна только внутри useEffect, проще оставить её там.
React отдельно показывает такой подход как способ убрать ненужную функциональную зависимость. useCallback не является универсальным способом исправления проблем с эффектами.
Самый неприятный вариант - setState зависит от самого себя
Например:
//jsx
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
}, [count]);Здесь массив зависимостей есть, но от этого ситуация не становится лучше.
Получается:
//text
count = 0
↓
effect
↓
setCount(1)
↓
count = 1
↓
effect
↓
setCount(2)
↓
count = 2
↓
effectcount является зависимостью эффекта и одновременно изменяется внутри этого эффекта.
Это классическая замкнутая цепочка: обновление состояния меняет зависимость, а изменение зависимости снова запускает эффект. Именно такую комбинацию React описывает как условие бесконечного цикла.
Если задача заключается в обновлении состояния на основе предыдущего значения, часто можно использовать функциональный updater:
//jsx
setCount(current => current + 1);Например, при создании интервала:
//jsx
useEffect(() => {
const timer = setInterval(() => {
setCount(current => current + 1);
}, 1000);
return () => clearInterval(timer);
}, []);Теперь эффекту не нужно читать текущее значение count из замыкания. Он получает предыдущее состояние непосредственно через updater, поэтому count не требуется добавлять в зависимости. React приводит именно такой подход для обновления состояния на основе предыдущего значения внутри эффекта.
При этом важно не делать из этого правило setState внутри useEffect - ошибка». Само обновление состояния из эффекта допустимо и встречается в официальных примерах React. Проблема появляется тогда, когда обновление состояния замыкает цепочку и снова меняет зависимость эффекта.
## Не удаляйте зависимость только ради исчезновения ошибки
Это очень распространённый способ «починки»:
Было:
//jsx
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);Стало:
//jsx
useEffect(() => {
fetchUser(userId).then(setUser);
}, []);Цикл или лишний запуск действительно может исчезнуть.
Но если userId изменится, эффект уже не отреагирует на это изменение.
В результате проблема не решена - её просто спрятали.
React прямо указывает, что реактивные значения, используемые внутри эффекта, должны быть указаны в списке зависимостей. Если зависимость кажется лишней, правильнее изменить окружающий код так, чтобы доказать, что это значение больше не является реактивной зависимостью.
Поэтому я бы осторожно относился и к отключению:
//jsx
// eslint-disable-next-line react-hooks/exhaustive-depsЕсли зависимости не соответствуют коду эффекта, это может привести к ошибкам. React рекомендует не подавлять предупреждение, а изменить код так, чтобы ненужная зависимость действительно исчезла.
Как я ищу бесконечный цикл в реальном компоненте
Когда компонент большой, смотреть только на useEffect мало смысла.
Я разбираю его по трём вопросам.
1. Что меняет эффект?
Например:
//jsx
setUser(data);или:
//jsx
setItems(data.items);или:
//jsx
setOptions(...);2. От чего зависит эффект?
Например:
//jsx
[userId, options, loadUser]3. Что из этих значений меняется после setState?
Именно третий вопрос обычно приводит к причине.
Для диагностики я могу временно вывести зависимости:
//jsx
console.log({
userId,
options,
loadUser
});Если userId остаётся тем же, а options при каждом рендере получает новую ссылку, причина уже практически найдена.
React для такой диагностики предлагает сохранять массив зависимостей в консоли и сравнивать отдельные значения через Object.is().
Например:
//jsx
console.log([userId, options, loadUser]);После нескольких рендеров можно сравнить конкретные элементы и выяснить, что именно постоянно меняется.
Не перепутайте цикл с двойным запуском в Strict Mode
Есть ещё ситуация, которая часто сбивает с толку.
В development-режиме при включённом Strict Mode React может выполнить дополнительную последовательность:
//text
setup
cleanup
setupпри первом запуске эффекта.
Это сделано специально: React проверяет, корректно ли работает логика эффекта и его очистка.
Поэтому два запуска при старте приложения ещё не означают бесконечный цикл.
Совсем другая картина выглядит так:
//text
setup
setup
setup
setup
setup
...и продолжается без остановки.
Если проблема проявляется только в development, сначала стоит проверить Strict Mode и функцию очистки.
Что проверить, если useEffect зациклился
Я бы шёл примерно в таком порядке:
Посмотреть, вызывает ли
useEffectsetState.Проверить, приводит ли это обновление к изменению одной из зависимостей.
Проверить объекты, массивы и функции в массиве зависимостей.
Найти значения, которые создаются заново при каждом рендере.
Посмотреть, нельзя ли перенести создание объекта или функции внутрь эффекта.
Проверить, не используется ли
useEffectдля обычного вычисления данных.Не удалять зависимости только ради исчезновения предупреждения.
Проверить Strict Mode, если эффект срабатывает дважды только при разработке.
Главное - не лечить симптом
У useEffect есть неприятная особенность: достаточно одного нестабильного значения, чтобы вполне нормальный на вид компонент начал вести себя странно.
Но сам бесконечный цикл обычно можно представить очень простой схемой:
//text
useEffect
↓
setState
↓
рендер
↓
изменилась зависимость?
↓
да
↓
useEffectЗадача не в том, чтобы любым способом разорвать эту цепочку.
Нужно найти почему зависимость изменилась.
Иногда это объект:
//jsx
const options = {};Иногда функция:
//jsx
const loadData = () => {};Иногда состояние:
//jsx
setCount(count + 1);А иногда выясняется, что весь эффект был лишним и данные можно было вычислять прямо во время рендера.
Поэтому я бы начинал отладку не с вопроса «как заставить useEffect запускаться реже?», а с другого:
какую внешнюю систему этот эффект должен синхронизировать и какое изменение заставляет его запускаться снова?
Если ответа нет, вполне возможно, что исправлять нужно не массив зависимостей. Нужно убрать сам useEffect.
Комментарии
Чтобы оставить комментарий, войдите в аккаунт.