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, чтобы ответ устаревшего запроса не попал в состояние.»