ПРАКТИЧЕСКИЙ РАЗБОР · 6 минут

Почему useEffect в React запускается дважды

Разбираем повторный запуск useEffect, зависимости, StrictMode и очистку подписок на простом примере React

Вы добавили useEffect, открыли страницу и увидели в консоли два одинаковых сообщения. Затем заметили, что обработчик события тоже срабатывает повторно. Появляется подозрение, что React выполняет код неправильно.

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

Для чего нужен useEffect

useEffect нужен, когда React-компонент взаимодействует с чем-то за пределами своего рендеринга. Например, подписывается на событие браузера, подключается к внешнему сервису или синхронизирует данные. Внутри эффекта можно создать подписку, а в возвращаемой функции отменить её.

"use client";

import { useEffect } from "react";

export default function Example() {
  useEffect(() => {
    console.log("Подключились");

    return () => {
      console.log("Отключились");
    };
  }, []);

  return <p>Откройте консоль браузера</p>;
}

При разработке вы можете увидеть последовательность «Подключились», «Отключились», «Подключились». Это не обязательно ошибка компонента.

Почему React запускает эффект повторно

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

Проверим это на обработчике изменения размера окна.

"use client";

import { useEffect, useState } from "react";

export default function WindowWidth() {
  const [width, setWidth] = useState<number | null>(null);

  useEffect(() => {
    function updateWidth() {
      setWidth(window.innerWidth);
    }

    updateWidth();
    window.addEventListener("resize", updateWidth);

    return () => {
      window.removeEventListener("resize", updateWidth);
    };
  }, []);

  return (
    <p>
      Ширина окна: {width ?? "определяем..."} px
    </p>
  );
}

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

Почему эффект срабатывает после каждого изменения

На частоту запуска влияет массив зависимостей, который передаётся вторым аргументом.

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

useEffect(() => {
  console.log("После подключения компонента");
}, []);

useEffect(() => {
  console.log("Изменился идентификатор", productId);
}, [productId]);

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

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

Когда useEffect вообще не нужен

Представим список товаров, где пользователь выбирает категорию. В компоненте уже есть массив товаров и выбранная категория. Можно отфильтровать список прямо при рендеринге.

const visibleProducts = products.filter(
  (product) => product.category === selectedCategory
);

Для такого вычисления не требуется отдельный useEffect, дополнительный useState и синхронизация между ними. Отфильтрованный список следует из уже имеющихся данных. Другое дело, когда изменение категории требует подписаться на внешний источник или выполнить действие за пределами React. Тогда эффект может быть оправдан.

В Next.js App Router появляется ещё одна граница. useEffect выполняется в браузере, а серверная загрузка данных может происходить без него. Поэтому далеко не каждый fetch нужно переносить в клиентский эффект.

Как искать причину повторного запуска

Начните с трёх вопросов. Работает ли приложение в режиме разработки со StrictMode? Есть ли в эффекте очистка созданных подписок? Какие значения указаны в массиве зависимостей? Для диагностики можно временно добавить сообщения в начало эффекта и в функцию очистки. Сравните, что происходит при первом открытии страницы, изменении параметров компонента и переходе на другой экран.

Отдельный сценарий связан с localStorage. Он доступен в браузере, но недоступен при серверном рендеринге. В моей статье на Habr о localStorage в Next.js разобрано, как учитывать эту границу без ошибок гидратации.

От одного эффекта к работающему приложению

В курсовом проекте Goods Finder можно посмотреть приложение с поиском товаров, маршрутизацией и клиентским состоянием.

Курс «Next.js I: JavaScript 2026» последовательно объясняет связь React и Next.js, серверную загрузку данных, компоненты и работу браузера.

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