React useEffect уходит в бесконечный цикл: как найти причину и исправить

mr. Cooper 5 дней назад Веб-разработка
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
 ↓
effect

count является зависимостью эффекта и одновременно изменяется внутри этого эффекта.

Это классическая замкнутая цепочка: обновление состояния меняет зависимость, а изменение зависимости снова запускает эффект. Именно такую комбинацию 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 зациклился

Я бы шёл примерно в таком порядке:

  1. Посмотреть, вызывает ли useEffect setState.

  2. Проверить, приводит ли это обновление к изменению одной из зависимостей.

  3. Проверить объекты, массивы и функции в массиве зависимостей.

  4. Найти значения, которые создаются заново при каждом рендере.

  5. Посмотреть, нельзя ли перенести создание объекта или функции внутрь эффекта.

  6. Проверить, не используется ли useEffect для обычного вычисления данных.

  7. Не удалять зависимости только ради исчезновения предупреждения.

  8. Проверить Strict Mode, если эффект срабатывает дважды только при разработке.

Главное - не лечить симптом

У useEffect есть неприятная особенность: достаточно одного нестабильного значения, чтобы вполне нормальный на вид компонент начал вести себя странно.

Но сам бесконечный цикл обычно можно представить очень простой схемой:

//text

useEffect
   ↓
setState
   ↓
рендер
   ↓
изменилась зависимость?
   ↓
да
   ↓
useEffect

Задача не в том, чтобы любым способом разорвать эту цепочку.

Нужно найти почему зависимость изменилась.

Иногда это объект:

//jsx

const options = {};

Иногда функция:

//jsx

const loadData = () => {};

Иногда состояние:

//jsx

setCount(count + 1);

А иногда выясняется, что весь эффект был лишним и данные можно было вычислять прямо во время рендера.

Поэтому я бы начинал отладку не с вопроса «как заставить useEffect запускаться реже?», а с другого:

какую внешнюю систему этот эффект должен синхронизировать и какое изменение заставляет его запускаться снова?

Если ответа нет, вполне возможно, что исправлять нужно не массив зависимостей. Нужно убрать сам useEffect.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи