Перейти к содержанию
Фронтенд

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

Список товаров перерисовывается без остановки, консоль заполняется сообщениями, а пустой массив зависимостей вроде бы решает проблему. Но такое изменение может оставить на экране старые данные. Надёжное исправление начинается с цепочки обратной связи: что записывает эффект, какой рендер следует за этим и какая зависимость меняется. Разберём небольшие примеры для клиентского компонента React, которые можно проверить и объяснить на собеседовании.

Автор: Опубликовано

7 мин чтенияРедакционный разборОбновлено
  • React
  • useEffect
  • Отладка
  • Фронтенд-собеседования
Главная мысль

Используйте эффект для синхронизации с внешней системой. Производные данные интерфейса вычисляйте при рендере, действия пользователя выполняйте в обработчиках, а внешние подключения делайте готовыми к очистке и повторному запуску.

Проследите обратную связь в сломанном списке товаров

Здесь products — массив объектов с числовым полем price. Каждый рендер создаёт новый объект options. Эффект фильтрует products в новый массив и записывает его в visible. Изменение состояния вызывает рендер, который создаёт очередной options, и эффект запускается снова. Даже при неизменных товарах и ценах ссылки на объект и массив продолжают меняться.

До правки нарисуйте конкретную цепочку: рендер создаёт options; эффект записывает новый visible; обновление состояния вызывает рендер; ссылка options меняется. Объяснение «объекты — плохие зависимости» недостаточно. Нестабильная зависимость может вызывать лишнюю синхронизацию без цикла; здесь обратную связь замыкает запись в состояние. Остановите пример после наблюдения, чтобы он не продолжал расходовать ресурсы.

'use client';
import { useEffect, useState } from 'react';

export default function BrokenPreview({ products }) {
  const [visible, setVisible] = useState([]);
  const options = { minimumPrice: 20 };

  useEffect(() => {
    setVisible(products.filter(
      product => product.price >= options.minimumPrice
    ));
  }, [products, options]);

  return <p>{visible.length} products</p>;
}

Точно прочитайте жизненный цикл и список зависимостей

Setup эффекта выполняется после фиксации изменений компонента. При изменении зависимости React вызывает предыдущий cleanup, затем новый setup; удаление компонента также вызывает cleanup. Значения зависимостей сравниваются через Object.is. Без массива эффект повторяется после каждого commit; пустой массив всё равно допускает setup при новом монтировании. Ни один вариант не означает «ровно один раз навсегда».

Для отладки запишите значения зависимостей рядом с последовательными зафиксированными рендерами и найдите изменившийся элемент. Числовая цена может быть прежней, а объект options с тем же свойством — новым. Также перечислите все функции изменения состояния, достижимые из эффекта, включая асинхронные колбэки и обработчики подписок. Затем проследите, влияет ли обновление на зависимость. Получится проверяемая гипотеза вместо перебора хуков наугад.

Вычисляйте производные данные непосредственно при рендере

Количество подходящих товаров полностью определяется products и minimumPrice. Отдельное состояние visible не требуется, внешней системы для синхронизации тоже нет. Уберите эффект и дополнительное состояние: следующий рендер сразу рассчитает правильный список. При изменении цены меняется одна величина состояния, а количество товаров следует из неё.

Проверьте товары с ценами 10, 20 и 30. При начальном минимуме 20 отображаются два товара, при 25 — один; замена products должна также менять результат. Пустой массив зависимостей в сломанном компоненте нарушил бы последнее требование. Если фильтрация действительно дорогая, можно кэшировать чистое вычисление, но useMemo решает вопрос производительности, а не определяет, что нужно хранить в состоянии.

'use client';
import { useState } from 'react';

export default function ProductPreview({ products }) {
  const [minimumPrice, setMinimumPrice] = useState(20);
  const visible = products.filter(
    product => product.price >= minimumPrice
  );

  return (
    <section>
      <label>
        Minimum price
        <input type="number" value={minimumPrice}
          onChange={event => setMinimumPrice(
            Number(event.target.value)
          )} />
      </label>
      <p>{visible.length} products</p>
    </section>
  );
}

Для настоящей подписки согласуйте setup и cleanup

Состояние подключения браузера приходит извне. Этот переиспользуемый хук подписывается на события online и offline, обновляет состояние и удаляет те же обработчики при очистке. Функция update создаётся внутри эффекта, поэтому очередной рендер не добавляет её как меняющуюся зависимость. Сначала хук возвращает true, затем читает текущее значение браузера после setup.

navigator.onLine — подсказка о подключении, а не доказательство доступности вашего API. Ошибки реальных запросов проверяйте отдельно. Setup здесь не читает меняющиеся props или состояние, поэтому пустой массив соответствует коду. Если настоящая подписка использует ID комнаты или URL, включите такие реактивные входы и создайте объект параметров внутри setup. Не скрывайте вход из списка ради уменьшения числа запусков.

'use client';
import { useEffect, useState } from 'react';

export function useOnlineStatus() {
  const [online, setOnline] = useState(true);

  useEffect(() => {
    function update() {
      setOnline(navigator.onLine);
    }
    update();
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
    return () => {
      window.removeEventListener('online', update);
      window.removeEventListener('offline', update);
    };
  }, []);

  return online;
}

Явное действие пользователя оставляйте в обработчике

Копирование текста должно происходить после нажатия Copy, а не из-за изменения text или монтирования компонента. Обработчик выполняет операцию браузера и обновляет статус в ответ на событие. Используйте его на защищённой странице с доступным Clipboard API: отсутствие поддержки или отказ в доступе перехватывается и показывает failed. При внедрении замените подписи статусов текстом вашего интерфейса.

Для сохранения черновика и отправки формы задайте тот же вопрос: какое действие пользователя запускает операцию? Запрос в эффекте, следящем за флагом submitted, скрывает эту причину и добавляет ещё один переход состояния для отладки. Для операций записи всё равно учитывайте повторы и повторную отправку на уровне приложения и сервера. Обработчик сам по себе не гарантирует выполнение ровно один раз.

'use client';
import { useState } from 'react';

export default function CopyButton({ text }) {
  const [status, setStatus] = useState('idle');

  async function handleCopy() {
    setStatus('copying');
    try {
      await navigator.clipboard.writeText(text);
      setStatus('copied');
    } catch {
      setStatus('failed');
    }
  }

  return (
    <>
      <button disabled={status === 'copying'}
        onClick={handleCopy}>Copy</button>
      <span role="status">{status}</span>
    </>
  );
}

Защитите асинхронный ответ при изменении запрошенного ID

Другой сбой возникает, когда пользователь открывает статью a и быстро переключается на b: медленный ответ a способен перезаписать b. Хук ниже предполагает непустой articleId и endpoint /api/articles/:id, возвращающий JSON статьи. Реализуйте endpoint или измените URL под своё приложение. Флаг active принадлежит одному setup: cleanup выключает его и отменяет соответствующий fetch. Проверка защищает и успешное обновление, и запись ошибки.

Результат хранится вместе с ID запроса. Если новый ID уже отрендерился, а эффект ещё не начал работу, хук возвращает loading вместо краткого показа предыдущей статьи. Внешний эффект остаётся синхронным и возвращает cleanup, асинхронная работа находится внутри load. Abort уменьшает ненужную работу, active запрещает устаревшему setup публиковать результат. Отмена запроса не откатывает действия, уже выполненные сервером.

Проверьте ответы с независимыми задержками: запросите a, переключитесь на b, разрешите сначала ответ b, затем a. На экране должна остаться b. Дополнительно проверьте ответ не из диапазона 2xx и размонтирование при незавершённом запросе. Хук демонстрирует привязку результата к запросу; в рабочем приложении механизмы загрузки и кэширования фреймворка могут решать дедупликацию, повторы и серверный рендеринг.

'use client';
import { useEffect, useState } from 'react';

export function useArticle(articleId) {
  const [result, setResult] = useState({
    id: null, data: null, error: null
  });

  useEffect(() => {
    let active = true;
    const controller = new AbortController();

    async function load() {
      try {
        const response = await fetch(
          '/api/articles/' + encodeURIComponent(articleId),
          { signal: controller.signal }
        );
        if (!response.ok) {
          throw new Error('HTTP ' + response.status);
        }
        const data = await response.json();
        if (active) {
          setResult({ id: articleId, data, error: null });
        }
      } catch (error) {
        if (active) {
          setResult({ id: articleId, data: null, error });
        }
      }
    }

    load();
    return () => {
      active = false;
      controller.abort();
    };
  }, [articleId]);

  if (result.id !== articleId) {
    return { status: 'loading', data: null, error: null };
  }
  return {
    status: result.error ? 'error' : 'ready',
    data: result.data,
    error: result.error
  };
}

Отличайте проверку в разработке от бесконечного цикла

При Strict Mode в корне приложения режим разработки включает дополнительную начальную проверку setup и cleanup перед повторным setup. Ограниченная последовательность setup, cleanup, setup отличается от непрерывных запусков из-за меняющихся зависимостей. Текущая документация React отдельно описывает Strict Mode вокруг поддерева: без Strict Mode в корне дополнительного начального запуска эффекта не будет.

Сохраните пользу этой проверки. В useOnlineStatus setup добавляет обработчики, cleanup удаляет их, поэтому повторный запуск не накапливает подписки. Ref, запрещающий следующий setup, может оставить компонент без подключения после cleanup и скрыть проблему. В production компоненты тоже могут монтироваться заново. Обеспечьте корректную синхронизацию после перезапуска, а не зависимость от определённого количества вызовов эффекта.

Проверка исправления и упражнение на 20 минут

Используйте список товаров как задачу для собеседования. Сначала объясните обратную связь без правок. Затем уберите производное состояние, добавьте управляемую пользователем минимальную цену и проверьте как замену props, так и изменение ввода. Наконец, объясните, что изменилось бы при подписке на внешний поток цен. Главный вопрос — какие значения можно вычислить, а какие нужно синхронизировать.

  • Назовите внешнюю систему или уберите эффект, если результат уже доступен из props и состояния.
  • Укажите конкретную запись в состояние и изменившуюся зависимость, замыкающие цикл: двух сообщений в консоли недостаточно.
  • Согласуйте зависимости с реактивными значениями, которые читает код. Для удаления зависимости меняйте код, а не подавляйте предупреждение.
  • Проверьте cleanup с незавершёнными запросами и активными подписками, затем быстрые изменения и размонтирование.
  • Объясните отвергнутое исправление: пустой массив заморозил бы список, а мемоизация options сохранила бы ненужное производное состояние.

Коротко

Частые вопросы

Почему useEffect вызывает бесконечный цикл?

Проследите обновление состояния из эффекта до рендера, меняющего его зависимость. Новые объекты, массивы и функции часто обнаруживают такую цепочку. Сначала уберите ненужное производное состояние, затем проверьте настоящую внешнюю синхронизацию.

Гарантирует ли пустой массив зависимостей один запуск?

Нет. Новое монтирование запускает setup заново, а Strict Mode в корне добавляет проверку при разработке. Пустой список подходит, только если setup не читает меняющиеся реактивные входы. Им нельзя безопасно заморозить вычисление, которое должно следовать за props.

Нужно ли исправлять каждый цикл через useCallback или useMemo?

Сначала решите, нужен ли эффект. Для производных данных интерфейса уберите его. Для внешней синхронизации организуйте код вокруг значимых входов и при необходимости создавайте служебные объекты внутри эффекта. Решение о кэшировании принимайте отдельно.

Можно ли сделать сам колбэк useEffect асинхронным?

Async-функция возвращает Promise, а колбэк эффекта должен вернуть cleanup или ничего. Объявите асинхронную функцию внутри setup и вызовите её там. Верните синхронный cleanup, который отменяет работу или запрещает результатам этого setup менять состояние.

Источники

Источники и редакционная политика

Сведения проверены 2 октября 2026 г. Ссылки рядом с разделами указывают источники фактов и технических объяснений. Выводы, учебные сценарии и рекомендации по подготовке — редакционная работа RecallDeck.

От чтения к воспроизведению

Отрепетируйте полный цикл интервью.

RecallDeck возвращает сложные темы по расписанию и помогает удерживать в памяти язык, SQL, архитектуру и поведенческие истории.

Начать подготовку

Продолжить подготовку