useEffect: зависимости, очистка и гонки запросов

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

useEffect — способ синхронизировать компонент с внешним миром: подписками, таймерами, сетью. Массив зависимостей отвечает на вопрос «когда синхронизацию нужно повторить».

Вопрос

«Как работает второй аргумент useEffect? В чём разница между отсутствием массива, пустым массивом и массивом со значениями? Зачем эффект возвращает функцию?»

Что на самом деле проверяет интервьюер

Умеете ли вы читать эффект как «синхронизацию», а не как «выполнить после рендера». За этим сразу следуют практические вопросы: утечки подписок, гонки сетевых запросов, повторные подписки при каждом рендере — на мобильном это выливается в реальные баги с батареей и памятью.

Развёрнутый ответ

Три варианта второго аргумента

ЗаписьКогда выполняется эффект
useEffect(fn)после каждого рендера
useEffect(fn, [])один раз, после монтирования
useEffect(fn, [a, b])после монтирования и при изменении a или b

Сравнение зависимостей — поверхностное, через Object.is. Это ключ ко всем последующим ловушкам.

Бесконечный цикл

// БАГ: эффект без зависимостей меняет состояние — рендер за рендером без остановки
useEffect(() => {
  setProfile({ ...profile, visits: profile.visits + 1 });
});

Тот же цикл получается, если положить в зависимости объект или массив, созданный в теле компонента. Проверим на чистом JS, почему так:

const makeParams = () => ({ page: 1, limit: 20 });

const prev = makeParams();
const next = makeParams();

console.log('одинаковое содержимое:', JSON.stringify(prev) === JSON.stringify(next));
console.log('одинаковая ссылка:', Object.is(prev, next));
console.log('строка сравнивается нормально:', Object.is('user-42', 'user-42'));

Вывод:

одинаковое содержимое: true
одинаковая ссылка: false
строка сравнивается нормально: true

Отсюда правило: в зависимостях держите примитивы (id, строку запроса, флаг), а не свежесозданные объекты. Если объект необходим — стабилизируйте его через useMemo или вынесите за пределы компонента.

Функция очистки

Возвращаемая из эффекта функция вызывается перед каждым следующим запуском эффекта и при размонтировании. Без неё подписки накапливаются:

import { useEffect } from 'react';
import { AppState } from 'react-native';

useEffect(() => {
  const sub = AppState.addEventListener('change', (state) => {
    if (state === 'active') refresh();
  });

  return () => sub.remove(); // без этого каждый рендер добавит новый слушатель
}, [refresh]);

Гонка запросов

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

useEffect(() => {
  let cancelled = false;
  const controller = new AbortController();

  async function load() {
    try {
      const res = await fetch(`https://api.example.com/users/${userId}`, {
        signal: controller.signal,
      });
      const data = await res.json();
      if (!cancelled) setUser(data); // ответ устаревшего запроса игнорируем
    } catch (e) {
      if (!cancelled && e.name !== 'AbortError') setError(e);
    }
  }

  load();
  return () => {
    cancelled = true;
    controller.abort();
  };
}, [userId]);

Чего в useEffect быть не должно

  • Вычислений, которые можно сделать прямо при рендере. Производное значение — это не эффект, а обычное выражение или useMemo.
  • Синхронизации состояния с props: useEffect(() => setFullName(first + ' ' + last), [first, last]) — лишний рендер, считайте прямо в теле компонента.
  • Тяжёлых синхронных расчётов — они блокируют JS-поток и подвешивают интерфейс.

Про StrictMode

В dev-режиме React специально монтирует компонент дважды, чтобы вскрыть отсутствующую очистку. Если эффект «ломается» от двойного запуска, это не баг React, а признак того, что эффект не идемпотентен.

Типичные ошибки кандидатов

  • Описывают эффекты в терминах жизненного цикла классов: «пустой массив — это componentDidMount». Аналогия рабочая, но она мешает увидеть логику синхронизации.
  • Убирают предупреждение линтера, вручную вычищая зависимости — так рождаются устаревшие замыкания.
  • Забывают функцию очистки у подписок, таймеров и слушателей — на мобильном это утечки и лишние пробуждения.
  • Не умеют объяснить, как отменить устаревший запрос, хотя это готовая задача из реального проекта.

Как ответить кратко

«useEffect — синхронизация с внешним миром. Без массива зависимостей эффект идёт после каждого рендера, с пустым — один раз после монтирования, с зависимостями — при их изменении, сравнение поверхностное через Object.is. Поэтому объект, созданный в теле компонента, ломает зависимости: у него каждый раз новая ссылка. Возвращаемая функция — очистка, она вызывается перед следующим запуском и при размонтировании: снимаем подписки, гасим таймеры, отменяем запрос через AbortController и флаг cancelled, чтобы ответ устаревшего запроса не попал в состояние.»

Проверьте себя
1. Что произойдёт, если в useEffect без массива зависимостей вызвать setState?
AНичего особенного, эффект просто выполнится дважды
BПолучится бесконечный цикл: setState вызывает рендер, рендер — эффект, эффект — setState
CReact выбросит ошибку на этапе сборки
DЭффект выполнится только при размонтировании
2. Зачем useEffect возвращает функцию?
AЧтобы React мог повторить эффект в случае ошибки
BЭто функция очистки: она вызывается перед следующим запуском эффекта и при размонтировании
CЧтобы вернуть результат эффекта в компонент
DЧтобы эффект стал синхронным
3. Почему объект, созданный прямо в теле компонента, нельзя просто положить в массив зависимостей?
AReact запрещает объекты в зависимостях и выбрасывает предупреждение
BНа каждом рендере создаётся новый объект с новой ссылкой, поэтому сравнение по Object.is всегда даёт «изменилось»
CОбъекты сравниваются по содержимому, и это слишком медленно
DОбъекты в зависимостях работают, но только в production-сборке