В консоли повторяется сообщение из useEffect. Найдём, что запускает эффект: изменение зависимости, проверка StrictMode или обновление состояния внутри самого эффекта. Затем исправим три компонента, сохранив нужное поведение.

Подготовьте пример и наблюдайте именно эффект

Нужен учебный проект React с Vite, базовый JavaScript и понимание состояния компонента. Если компоненты пока незнакомы, начните с введения в React. В этой практике будем читать зависимости и следить за тем, что меняется после рендера.

Запускайте проект через npm run dev и откройте консоль браузера. Примеры ниже можно по очереди вставлять в src/App.jsx. Соберём их вместе.

Для наблюдения ставьте console.count() внутри эффекта и подписывайте счётчик. Сообщение в теле компонента показывает вызов компонента; оно не доказывает запуск эффекта. Это разные события.

Мы рекомендуем начинать поиск с вопроса, нужен ли эффект вообще. Если значение вычисляется из других значений React, часто достаточно вычисления во время рендера. Эффект нужен для синхронизации с внешней системой, например с подпиской браузера или таймером. Когда эффект можно убрать.

Воспроизведите цепочку обновлений

Замените src/App.jsx этим компонентом. Ограничение до пяти оставлено для наблюдения: оно позволяет увидеть цепочку и останавливает её.

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.count("effect: count");
    if (count < 5) {
      setCount(count + 1);
    }
  }, [count]);

  return <p>Счётчик: {count}</p>;
}

Ничего не нажимайте. Счётчик должен самостоятельно дойти до пяти. Эффект читает count, меняет его и тем самым запускает следующий рендер. Зависимость получает новое значение, после чего эффект выполняется снова.

Уберите условие мысленно: setCount(count + 1) продолжит обновлять собственную зависимость без остановки. Так возникает цикл. Для воспроизведения механизма вам достаточно ограниченного примера; бесконечное обновление в браузере запускать не требуется.

В справочнике React цикл описан двумя условиями: эффект меняет состояние, а обновление этого состояния приводит к изменению его зависимостей. Диагностика бесконечного цикла.

Как исправить компонент, зависит от задачи. Если счётчик должен увеличиваться после нажатия, перенесите действие в обработчик события:

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      Счётчик: {count}
    </button>
  );
}

Теперь пользовательское действие меняет состояние один раз. Эффект удалён, потому что внешнюю систему здесь синхронизировать не нужно. Замена зависимости на пустой массив сохранила бы непонятную связь состояния с монтированием компонента; задача яснее выражена обработчиком.

Отделите StrictMode от цикла

В проекте React часто есть StrictMode вокруг приложения. Если StrictMode включён у корня приложения, в разработке он выполняет дополнительный цикл настройки и очистки эффекта. Это проверка того, что очистка отменяет работу настройки. В производственной сборке эта дополнительная проверка не выполняется. Повторный запуск эффектов в StrictMode.

Чтобы увидеть разницу, используйте эффект с явными сообщениями:

useEffect(() => {
  console.log("setup");
  return () => {
    console.log("cleanup");
  };
}, []);

Этот фрагмент вставляют внутрь компонента. Если StrictMode включён у корня приложения, в разработке при первом монтировании вы увидите последовательность настройки, очистки и новой настройки. Сообщения не должны бесконечно добавляться сами по себе.

Дополнительная проверка на старте и повторения при изменении зависимостей могут встретиться вместе. Поэтому считать количество строк недостаточно. Смотрите, какие значения меняются и выполняется ли очистка.

Отключение StrictMode убирает проверку, но не исправляет ошибку подписки или цикл данных. Сохраняйте проверку и приводите очистку в соответствие с настройкой.

Проверьте, какая зависимость изменилась

Есть три распространённых записи:

useEffect(() => {
  console.log("После каждого завершённого рендера");
});

useEffect(() => {
  console.log("После монтирования");
}, []);

useEffect(() => {
  console.log("Изменился query:", query);
}, [query]);

Без массива эффект повторяется после каждого рендера, результат которого React применил к странице. Пустой массив означает, что в эффекте нет изменяемых зависимостей из компонента; при повторном монтировании он выполнится снова. При [query] React сравнивает текущее и предыдущее значение зависимости с помощью Object.is. Отдельная проверка StrictMode в разработке остаётся и при пустом массиве.

Массив нужно составлять по тому, что эффект читает. Если внутри используются параметры компонента, состояние или объявленные в компоненте значения, линтер проверяет их как зависимости. Не отключайте правило react-hooks/exhaustive-deps, чтобы заглушить предупреждение. Измените код так, чтобы зависимости соответствовали задаче. Как правильно убирать зависимости.

Найдите изменившуюся зависимость. Выведите массив зависимостей в консоль. Сохраните массивы двух рендеров через Store as global variable и сравните соответствующие элементы:

Object.is(temp1[0], temp2[0]);
Object.is(temp1[1], temp2[1]);

Названия temp1 и temp2 здесь обозначают сохранённые в консоли массивы. Результат false показывает изменение конкретной зависимости. После диагностики удалите временные сообщения, чтобы они не мешали дальнейшей работе.

Уберите объект, который создаётся при каждом рендере

Во втором компоненте эффект меняет заголовок вкладки. Он должен реагировать на поисковую строку, но зависит от объекта:

function PageTitle({ query }) {
  const options = { prefix: "Каталог", query };

  useEffect(() => {
    console.count("effect: title");
    document.title = options.prefix + ": " + options.query;
  }, [options]);

  return null;
}

Даже с прежними полями options при следующем вызове компонента будет новым объектом. Например, родитель обновил поле заметки, и PageTitle тоже отрендерился. Эффект увидит изменившуюся ссылку и снова обновит заголовок.

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

Зависимостью будет сама поисковая строка:

function PageTitle({ query }) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = query ? "Поиск: " + query : "Каталог";

    return () => {
      document.title = previousTitle;
    };
  }, [query]);

  return null;
}

Теперь эффект читает query, а зависимость соответствует этому чтению. Очистка возвращает прежний заголовок перед сменой настройки или удалением компонента.

Если объект нужен внешнему API, часто его можно создать внутри эффекта из примитивных зависимостей. useMemo и useCallback оставляйте для случаев, где действительно требуется сохранять значение или функцию. Они не должны закрывать неясную схему обновлений.

Рассчитайте список без дополнительного состояния

Третий компонент хранит отфильтрованные товары в состоянии:

const [visibleProducts, setVisibleProducts] = useState([]);

useEffect(() => {
  setVisibleProducts(
    products.filter((product) => product.title.includes(query))
  );
}, [products, query]);

После изменения запроса компонент сначала рендерится со старым списком, потом эффект записывает новый список и вызывает ещё одно обновление. Здесь нет внешней системы. Мы можем получить список прямо из исходных товаров и запроса.

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

Соберите исправленный пример с нужным эффектом

Верните src/main.jsx к понятной точке запуска. В шаблоне уже есть элемент root:

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App />
  </StrictMode>
);

В src/App.jsx запишите полный компонент. Часы показывают случай, где эффект остаётся: таймер работает вне React, и его нужно выключить при удалении компонента.

import { useEffect, useState } from "react";

const products = [
  { id: "lamp", title: "Лампа" },
  { id: "chair", title: "Стул" }
];

function PageTitle({ query }) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = query ? "Поиск: " + query : "Каталог";
    return () => {
      document.title = previousTitle;
    };
  }, [query]);

  return null;
}

function Clock() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timerId = window.setInterval(() => {
      setSeconds((value) => value + 1);
    }, 1000);

    return () => {
      window.clearInterval(timerId);
    };
  }, []);

  return <p>Прошло секунд: {seconds}</p>;
}

export default function App() {
  const [query, setQuery] = useState("");
  const [note, setNote] = useState("");
  const [count, setCount] = useState(0);
  const [showClock, setShowClock] = useState(true);

  const visibleProducts = products.filter((product) =>
    product.title.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <main>
      <PageTitle query={query} />
      <h1>Учебный каталог</h1>

      <label>
        Поиск
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>

      <ul>
        {visibleProducts.map((product) => (
          <li key={product.id}>{product.title}</li>
        ))}
      </ul>

      <label>
        Заметка
        <input value={note} onChange={(e) => setNote(e.target.value)} />
      </label>

      <button onClick={() => setCount((value) => value + 1)}>
        Счётчик: {count}
      </button>

      <button onClick={() => setShowClock((value) => !value)}>
        {showClock ? "Скрыть часы" : "Показать часы"}
      </button>

      {showClock && <Clock />}
    </main>
  );
}

Функциональная запись setSeconds((value) => value + 1) берёт предыдущее состояние для обновления. Эффект не читает seconds, поэтому таймеру не нужно пересоздаваться на каждую секунду. При скрытии часов очистка останавливает интервал.

При повторном показе создаётся новый компонент часов с нулём секунд. Это повторное монтирование, поэтому эффект снова запускается. Даже в рабочей сборке компоненты могут монтироваться повторно; пустой массив не обещает один запуск за всё время жизни приложения.

Проверьте каждое исправление по поведению

ДействиеОжидаемый результат
Открыть пример и ничего не нажиматьСчётчик остаётся на нуле; часы увеличиваются.
Нажать кнопку счётчикаЗначение увеличивается после каждого нажатия.
Ввести «лам» в поискВ списке остаётся лампа, заголовок вкладки меняется.
Изменить заметкуПоиск и заголовок сохраняют прежние значения.
Скрыть часыТаймер очищается, компонент исчезает.
Снова показать часыНовый счётчик времени начинает с нуля.

Теперь измените таймер. Для самостоятельной задачи добавьте задержку через параметр delay. Переключайте её между 1000 и 2000 миллисекундами. Подумайте, какое значение эффект должен читать и включать в зависимости, а затем проверьте, что прежний интервал очищается при смене задержки.

Продолжите практику после трёх исправлений

Исправьте счётчик, зависимость от объекта и лишнее состояние списка в своём учебном проекте. Если можете объяснить каждый оставшийся эффект и его очистку, сверяйте программу курса «React. Разработка сложных клиентских приложений» и входные требования по JavaScript. Следующая практика будет опираться на это понимание обновлений.


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники