React useEffect срабатывает два раза: почему появляются дублирующие запросы

mr. Cooper 1 неделю назад Веб-разработка
React useEffect срабатывает два раза: почему появляются дублирующие запросы

Если useEffect в React срабатывает два раза, а во вкладке Network появляются два одинаковых запроса, не спешите искать второй вызов в коде. В development это часто связано с StrictMode: React специально запускает Effect повторно, чтобы проверить, правильно ли код освобождает ресурсы после первого запуска. Поэтому обычный useEffect с пустым массивом зависимостей может привести к двум fetch в режиме разработки, хотя в production такого дополнительного запуска при первоначальном монтировании нет.

Я бы в такой ситуации первым делом не отключал StrictMode. Сначала стоит посмотреть, что именно делает Effect и что происходит с его первым запуском.

Откуда берётся второй запрос

Допустим, компонент выглядит так:

//jsx

function Users() {
  useEffect(() => {
    fetch('/api/users');
  }, []);

  return <div>Users</div>;
}

Логика кажется очевидной: компонент появился - отправили запрос.

Но при включённом StrictMode в development React выполняет дополнительную последовательность:

//text

setup
↓
cleanup
↓
setup

Это не обычный двойной рендер ради самого рендера. React проверяет именно жизненный цикл Effect: сможет ли он корректно остановить то, что запустил. Официальная документация React прямо называет это development-only stress-test для cleanup.

В нашем примере cleanup вообще нет.

Первый useEffect вызвал fetch(), а затем React перешёл к следующему запуску. Второй useEffect снова вызвал fetch().

Поэтому в Network можно увидеть:

//text

GET /api/users
GET /api/users

На этом месте я раньше скорее думал бы: «Почему React отправляет запрос два раза?»

Сейчас я бы сформулировал вопрос иначе: что произойдёт с первым запросом, когда Effect перестанет быть актуальным?

Это уже гораздо полезнее.

Пустой массив зависимостей не означает «один раз навсегда»

Вот эта запись:

//jsx

useEffect(() => {
  // ...
}, []);

часто воспринимается как команда React: «выполни этот код ровно один раз».

Но это слишком грубое объяснение.

Пустой массив означает, что Effect не имеет реактивных зависимостей. Он не будет повторно синхронизироваться из-за изменения props или state, которые должны были бы находиться в массиве зависимостей. При этом сам компонент может быть смонтирован снова, а StrictMode в development специально проверяет Effect дополнительным запуском.

Поэтому я бы не строил важную логику приложения на предположении, что [] каким-то образом гарантирует единственный запуск за всё время существования приложения.

У Effect другая задача - синхронизировать компонент с внешней системой. Это может быть соединение, подписка, таймер, браузерный API или загрузка данных.

Что делать с fetch

Если Effect отправляет запрос, ему может понадобиться cleanup.

Один из вариантов - отменять ненужный fetch:

//jsx

useEffect(() => {
  const controller = new AbortController();

  fetch('/api/users', {
    signal: controller.signal
  })
    .then(response => response.json())
    .then(data => {
      console.log(data);
    })
    .catch(error => {
      if (error.name !== 'AbortError') {
        console.error(error);
      }
    });

  return () => {
    controller.abort();
  };
}, []);

Теперь у запроса есть жизненный цикл.

Effect начинает работу:

//text

setup
↓
fetch

А cleanup может её остановить:

//text

cleanup
↓
abort()

Но здесь есть важная деталь.

AbortController не превращает уже отправленный HTTP-запрос в никогда не существовавший. Если запрос успел уйти, сервер мог его получить. Поэтому задача cleanup не в том, чтобы магическим образом убрать строку из Network.

Задача в том, чтобы устаревший запрос больше не продолжал влиять на состояние приложения.

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

Иногда отменять запрос вообще недостаточно

Представим другой пример:

//jsx

useEffect(() => {
  fetch('/api/order', {
    method: 'POST'
  });
}, []);

Здесь уже не просто загрузка списка пользователей.

Мы отправляем операцию, которая что-то меняет на сервере.

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

И здесь я бы вообще задумался, нужен ли useEffect.

Если запрос должен отправляться после нажатия кнопки, причина запуска - не появление компонента. Причина запуска - действие пользователя.

Тогда код лучше выглядит так:

//jsx

function BuyButton() {
  async function handleBuy() {
    await fetch('/api/order', {
      method: 'POST'
    });
  }

  return (
    <button onClick={handleBuy}>
      Купить
    </button>
  );
}

Теперь всё довольно прозрачно: человек нажал кнопку - отправился запрос.

React отдельно рекомендует разделять Effects и обработчики событий. Event handler запускается в ответ на конкретное взаимодействие пользователя, тогда как Effect нужен для синхронизации с внешней системой.

Когда проблема действительно не в StrictMode

Допустим, StrictMode вообще отключён, а запрос всё равно появляется несколько раз.

Тогда я начинаю смотреть на зависимости.

Первый подозреваемый - Effect без массива:

//jsx

useEffect(() => {
  fetch('/api/users');
});

Если массив зависимостей не указан, Effect запускается после каждого commit компонента.

Можно получить совсем неприятную цепочку, если Effect ещё и меняет состояние:

//text

Effect
↓
setState()
↓
render
↓
Effect
↓
setState()
↓
render

В таком случае второй запрос уже никак нельзя списать на StrictMode.

Есть и другая распространённая причина - зависимости, которые меняются на каждом рендере.

Например:

//jsx

function Users() {
  const options = {
    page: 1
  };

  useEffect(() => {
    loadUsers(options);
  }, [options]);

  return <div>Users</div>;
}

На каждом рендере создаётся новый объект options.

Для React это уже другое значение, потому что зависимости сравниваются через Object.is. В результате Effect может запускаться снова, хотя содержимое объекта визуально не изменилось.

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

Почему useRef здесь не лучший выход

Есть довольно соблазнительное решение:

//jsx

const hasRun = useRef(false);

useEffect(() => {
  if (hasRun.current) {
    return;
  }

  hasRun.current = true;

  fetch('/api/users');
}, []);

В консоли становится тише. Второй вызов блокируется.

Но сам Effect от этого лучше не стал.

React прямо рассматривает использование ref для подавления повторного запуска Effect как распространённую ошибку. Такой код маскирует проблему вместо того, чтобы сделать setup и cleanup устойчивыми к повторному запуску.

Если Effect создаёт подписку или соединение, например, нам всё равно придётся его корректно закрывать.

//jsx

useEffect(() => {
  const connection = createConnection();

  connection.connect();

  return () => {
    connection.disconnect();
  };
}, []);

Здесь повторный запуск уже не выглядит страшным:

//text

connect
↓
disconnect
↓
connect

После проверки остаётся одно активное соединение.

Именно такого поведения React и ожидает от хорошо написанного Effect: пользователь не должен замечать разницу между обычным запуском и последовательностью setup → cleanup → setup.

Почему отключать StrictMode я бы не стал

Технически отключить его можно.

Но тогда исчезнет только проверка.

Например, был такой код:

//jsx

useEffect(() => {
  const connection = createConnection();

  connection.connect();
}, []);

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

Но если компонент позже начнёт размонтироваться и монтироваться снова, отсутствие disconnect() никуда не исчезнет.

React как раз приводит StrictMode как способ обнаружить такие ошибки раньше. В production дополнительного development-цикла нет, но сама проблема с отсутствующим cleanup может проявиться при реальном повторном монтировании или изменении зависимостей.

Поэтому лично для меня две одинаковые строки в Network - не повод выключать проверку.

Это повод посмотреть на Effect внимательнее.

Что я проверяю первым

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

Во-первых, включён ли StrictMode.

Во-вторых, есть ли у Effect зависимости и действительно ли они должны быть такими.

В-третьих, не создаются ли объекты или функции из массива зависимостей заново на каждом рендере.

В-четвёртых, есть ли cleanup.

И наконец - действительно ли эта операция относится к Effect.

Последний вопрос оказался для меня самым полезным.

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

Если пользователь нажал «Купить», это событие.

Если компонент должен поддерживать соединение с сервером, это Effect.

Если нужно получить данные для компонента, Effect может подойти, хотя в современных React-фреймворках часто есть более подходящие механизмы загрузки данных. React сам отмечает, что ручной fetch внутри Effects быстро приводит к повторяющемуся коду и усложняет такие вещи, как кэширование и серверный рендеринг.

Так что происходит с двумя запросами

В простом случае причина действительно может быть очень прозаичной:

//jsx

<StrictMode>
  <App />
</StrictMode>

и:

//jsx

useEffect(() => {
  fetch('/api/users');
}, []);

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

Но дальше начинается уже работа разработчика.

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

Если Effect создаёт соединение или подписку, нужен cleanup.

Если запрос выполняет действие пользователя, возможно, его вообще стоит перенести в event handler.

Если операция критична и повторный запрос опасен, React не должен быть единственным уровнем защиты - серверная часть тоже должна учитывать возможность повторной отправки.

После этого двойной useEffect перестаёт выглядеть как странный баг React.

Для меня здесь главный вывод довольно простой: не нужно любой ценой добиваться, чтобы Effect запустился только один раз.

Гораздо важнее сделать его таким, чтобы повторный цикл

//text

setup → cleanup → setup

не ломал приложение.

Если код это выдерживает, StrictMode скорее помогает, чем мешает. А если не выдерживает - лучше узнать об этом в development, увидев две строки в Network, чем обнаружить ту же ошибку уже после публикации приложения.

Комментарии

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

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

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