12k
All articles

Почему useEffect срабатывает дважды

Узнайте, почему React StrictMode запускает useEffect дважды в режиме разработки и как исправить запросы, подписки и другие эффекты с помощью очистки.

OpenReplay Team
OpenReplay Team
Почему useEffect срабатывает дважды

В режиме разработки React StrictMode выполняет для каждого эффекта функцию настройки (setup), затем функцию очистки (cleanup), а затем снова настройку. Поэтому любой эффект, который загружает данные, подписывается на события или пишет в лог, заметно срабатывает дважды.

Вы пишете один fetch в эффекте, открываете вкладку Network и видите два одинаковых запроса и две записи в консоли. Похоже на баг в вашем коде или в самом React.

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

Ключевые выводы

  • StrictMode добавляет для каждого эффекта один лишний цикл настройки и очистки. Это происходит только в режиме разработки, как в React 18, так и в React 19.
  • StrictMode включается явно, но шаблон React для Vite оборачивает приложение в <StrictMode>, а в Next.js App Router этот режим включён по умолчанию.
  • Корректная функция очистки отменяет ровно то, что сделала настройка: прерывает запрос, удаляет обработчик, сбрасывает таймер или закрывает соединение.
  • Два запроса во вкладке Network в режиме разработки ожидаемы даже после правильного исправления. Важно лишь, чтобы состояние мог обновить только последний ответ.
  • Отключение StrictMode или защитный флаг «уже выполнялось» на useRef скрывают отсутствие очистки, но не устраняют проблему.

Симптом: fetch срабатывает дважды

Чаще всего с этим поведением сталкиваются через эффект загрузки данных без очистки. Этот компонент нормально работает в продакшене, но в режиме разработки срабатывает дважды:

import { useState, useEffect } from 'react';

function ProductDetails({ id }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    console.log('setup');
    fetch(`/api/products/${id}`)
      .then((res) => res.json())
      .then((data) => setProduct(data));
  }, [id]);

  return <h2>{product?.name}</h2>;
}

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

Почему useEffect срабатывает дважды в режиме разработки?

Как поясняет справочник по React StrictMode, при включённом StrictMode React в режиме разработки добавляет к каждому эффекту ещё один проход настройки и очистки. Эффект выполняет настройку, затем очистку, затем снова настройку, как будто компонент смонтировался, размонтировался и тут же смонтировался снова.

Двойной запуск происходит только в сборках для разработки. В продакшене эффект срабатывает один раз при монтировании компонента и повторно только при изменении зависимостей или повторном монтировании.

StrictMode включается явно и действует только на компоненты, обёрнутые в <StrictMode>. Однако многие проекты обёрнуты по умолчанию. Файл main.jsx шаблона React для Vite рендерит <App /> внутри <StrictMode>. В Next.js App Router StrictMode включён по умолчанию начиная с Next.js 13.5.1. В приложениях на Pages Router его нужно включить вручную через reactStrictMode: true.

Этот цикл проверяет, действительно ли работает ваша очистка. Если эффект ведёт себя некорректно, когда React выполняет настройку, очистку и снова настройку, он поведёт себя так же, когда пользователь уйдёт со страницы и вернётся. То же случится при повторном запуске после правок кода в режиме разработки: в документации Next.js по Fast Refresh сказано, что эффекты должны переносить периодический повторный запуск и что StrictMode это требование обеспечивает. Подробнее о том, когда именно срабатывает очистка относительно отрисовки, читайте в статье useEffect vs useLayoutEffect.

Как исправить useEffect, который срабатывает дважды?

Чтобы исправить такой useEffect, добавьте функцию очистки, которая отменяет то, что запустила настройка. У каждой типичной ошибки есть своё решение:

Что вы видите в режиме разработкиВ чём на самом деле проблемаРешение
Два запроса, возможны устаревшие данныеНичто не отменяет выполняющийся запросAbortController или флаг ignore в очистке
Обработчик или подписка срабатывает дваждыИх никогда не удаляютУдалять в очистке
Счётчик доходит до 2Логика не является побочным эффектомПеренести в обработчик события

Запрос fetch срабатывает дважды

Для fetch, который дважды срабатывает в useEffect, есть два решения. Первое — прерывать запрос в очистке. Прерванный fetch отклоняется с DOMException AbortError, и эту ошибку можно спокойно игнорировать:

useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/products/${id}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setProduct(data))
    .catch((err) => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, [id]);

Второй вариант — флаг ignore. Именно этот паттерн для загрузки данных используется в руководстве React по синхронизации с помощью эффектов:

useEffect(() => {
  let ignore = false;
  fetch(`/api/products/${id}`)
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setProduct(data);
    });
  return () => {
    ignore = true;
  };
}, [id]);

После любого из этих исправлений в режиме разработки вы всё равно увидите два запроса. С AbortController первый запрос прерывается. С ignore оба запроса завершаются, но обновить состояние может только второй. В продакшене отправляется один запрос. Та же очистка не даёт медленному ответу перезаписать более свежий при изменении id.

Утечка обработчика событий

Обработчик событий, добавленный в useEffect, приводит к утечке, если очистка его не удаляет. Объявляйте обработчик внутри эффекта, чтобы очистка удаляла ту же ссылку на функцию, которую добавила настройка:

useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

Счётчик увеличивается дважды

// Before: ends at 2 in development
useEffect(() => {
  setCount((c) => c + 1);
}, []);

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

<button onClick={() => setCount((c) => c + 1)}>Add</button>

Стоит ли убрать StrictMode или добавить защиту через useRef?

Нет. Оба подхода убирают двойной запуск, но оставляют исходный баг на месте.

Отключение StrictMode, будь то удаление обёртки или установка reactStrictMode: false в конфигурации Next.js, лишь убирает саму проверку. Очистки в эффектах по-прежнему нет.

Защита через useRef выглядит соблазнительнее:

// Avoid: hides missing cleanup
const hasRun = useRef(false);

useEffect(() => {
  if (hasRun.current) return;
  hasRun.current = true;
  subscribe();
}, []);

Такая защита пропускает вторую настройку в режиме разработки, и консоль выглядит чистой. Но флаг на useRef, пропускающий второй запуск, не заменяет отсутствующую очистку. Он скрывает проблему в режиме разработки и оставляет утечку при каждом реальном повторном монтировании. ref принадлежит одному экземпляру компонента. Когда маршрут в продакшене размонтируется и монтируется снова, новый экземпляр получает новый ref, и эффект выполняется повторно. Очистки по-прежнему нет, поэтому старая подписка так и не удаляется.

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

Когда эффект не нужен

Многие эффекты, срабатывающие дважды, вообще не должны были быть эффектами. Эффекты нужны для синхронизации с чем-то вне React, обусловленной тем, что компонент находится на экране. В статье You Might Not Need an Effect разобраны две самые большие группы ненужных эффектов.

Работа, вызванная событиями, должна выполняться в обработчиках событий. Отправка формы, показ уведомления после нажатия «Добавить в корзину» или увеличение счётчика происходят потому, что пользователь что-то сделал, а не потому, что компонент отрендерился.

Значения, которые можно вычислить из пропсов или состояния, вычисляются во время рендера:

// Before: extra state, extra render, effect runs twice
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(first + ' ' + last);
}, [first, last]);

// After: computed during render
const fullName = first + ' ' + last;

Быстрая проверка: корректен ли ваш эффект?

Чтобы проверить корректность useEffect, добавьте логи и в настройку, и в очистку:

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

В режиме разработки в консоли должно появиться:

setup
cleanup
setup

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

Заключение

useEffect, срабатывающий дважды в режиме разработки, — это StrictMode, проверяющий работу вашей очистки. Не заглушайте эту проверку. Пройдитесь по всем эффектам, которые срабатывают дважды, и добейтесь, чтобы они её проходили: прерывайте или игнорируйте устаревшие запросы, удаляйте всё, что добавляете, а логику, вызванную событиями, и производные значения полностью выносите из эффектов. Когда эффекты начнут корректно выполнять очистку, добавьте границы ошибок React (error boundaries), чтобы компонент, выбросивший ошибку при рендере, не ронял всю страницу.

Часто задаваемые вопросы

Почему мой useEffect срабатывает дважды в продакшене, где StrictMode выключен?

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

Нужно ли предотвращать двойную отправку аналитического события в режиме разработки?

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

useLayoutEffect тоже срабатывает дважды в StrictMode?

Да. Справочник React по useLayoutEffect описывает то же поведение в режиме разработки, что и для useEffect: при включённом StrictMode React сначала выполняет проход настройки и очистки, а затем настоящую настройку. Эффектам макета нужна такая же парная очистка, например отключение observer или уничтожение экземпляра стороннего виджета. Переход на useLayoutEffect меняет момент запуска эффекта относительно отрисовки, но не количество его запусков в режиме разработки.

Можно ли отключить StrictMode для одного компонента и оставить его для остального приложения?

Нет. Если дерево обёрнуто в StrictMode, проверки получают все компоненты внутри него, и отдельный компонент не может от них отказаться. Зато можно перенести обёртку StrictMode ниже по дереву, чтобы она охватывала только часть приложения. Если StrictMode не оборачивает корень, React не выполняет дополнительный запуск эффектов при первом монтировании. Иначе эффекты дочерних компонентов срабатывали бы дважды, а эффекты родителей — один раз, что невозможно в продакшене.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.