Хук useEffect формально описан одной страницей справочника: синхронизируй компонент с внешней системой, верни функцию очистки, перечисли зависимости. Одна страница, а за ней годы холиваров, гигабайты переписанных в полночь компонентов и целый склад подавленных линтером предупреждений. Разрыв между текстом документации и практикой случился не из-за качества текста, а из-за того, что реальный код живёт в условиях, которых у документации нет: объекты-пропсы из чужого компонента, гонки сетевых ответов, интервалы, помнящие прошлое. Ниже разбор того, где формальное следование правилам доводит до глюков, какие ловушки расставляет массив зависимостей и как официальные рекомендации звучат на языке живого проекта.
Что официальная документация говорит о назначении эффекта
Позиция команды React на удивление категоричная: эффект это люк для побега из парадигмы, "escape hatch". Хук нужен для синхронизации с внешними по отношению к React системами: подписка на сокет сервера, подключение не-React виджета, таймер, прямая работа с DOM. Если внешней системы нет, эффект не нужен. Если нужно получить сумму из двух полей формы, эффект тем более не нужен.
Механика описана честно и жёстко. Эффект запускается после коммита, то есть после отрисовки, а не вместо неё. Функция очистки вызывается перед каждым повторным запуском и при размонтировании. Массив зависимостей сравнивается по значению, через Object.is, а не по смыслу. И главное правило, вокруг которого всё ломается: в массиве должны стоять все значения, которые эффект читает, иначе он увидит устаревшее.
У хука есть старший брат useLayoutEffect, который срабатывает до отрисовки и нужен для измерения геометрии DOM без мигания. Практическое правило простое: сначала useEffect, и лишь при доказанном визуальном дефекте думать про layout-вариант.
Официальный линтер exhaustive-deps из пакета eslint-plugin-react-hooks вцепляется в любое отступление и не отпускает. Документация вдобавок предупреждает: подавление предупреждения должно быть осознанным решением, а не рефлексом. Именно здесь проходит водораздел между двумя лагерями команд, одни живут по правилу буква в букву, другие отключают линтер и надеются.
Массив зависимостей и сравнение по ссылкам на живых примерах
Правило "перечисли все зависимости" звучит просто, пока в зависимостях не оказывается объект. Object.is сравнивает ссылку, а ссылка у свежесозданного объекта каждый раз новая, сколько ни сравнивай:
function Parent() {
const [tick, setTick] = useState(0);
return <Report filters={{ status: 'open' }} />;
}
function Report({ filters }) {
const [rows, setRows] = useState([]);
useEffect(() => {
loadRows(filters).then(setRows);
}, [filters]); // новый объект каждый рендер родителя
}
Каждый рендер Parent создаёт новый литерал объекта, эффект считает ссылку изменённой, запрос уходит заново, ответ обновляет состояние, состояние перерисовывает родителя, и колесо крутится, пока живёт вкладка. Разработчик читает код и не видит ошибки: значения-то в filters одинаковые, меняется только коробка.
Рецептов два, оба официальные. Первый, стабилизировать ссылку у источника:
const filters = useMemo(() => ({ status: 'open' }), []);
Функции в зависимостях страдают той же болезнью, и рецепт тоже стандартный, обёртка useCallback удерживает ссылку, пока не изменились её собственные зависимости. Подписка на сокет без неё перезапускалась бы на каждый чих родителя:
const onMessage = useCallback((msg) => {
setMessages((prev) => [...prev, msg]);
}, []);
useEffect(() => {
socket.on('message', onMessage);
return () => socket.off('message', onMessage);
}, [onMessage]);
Парный socket.off в функции возврата делает подписку симметричной, и этот фрагмент стоит показывать новичкам как образец. Для объектов-пропсов рецепт другой, спуститься до примитивов и передавать status строкой, тогда сравнение по значению работает честно. Хуже всего, что обе ошибки дают одинаковый невнятный симптом, лишние запросы в сети, и без вкладки Network причину не найти.
Каскадные обновления и производное состояние внутри эффектов
Самый частый антипаттерн из официального сборника выглядит невинно. Разработчик хочет, чтобы поле fullName обновлялось при смене firstName и lastName, и пишет эффект:
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
Документация разбирает этот пример хладнокровно: код и сложнее, чем нужно, и медленнее. React сначала рисует экран с устаревшим fullName, коммитит его, потом запускает эффект, тот вызывает setState, и React перерисовывает всё заново. Два прохода рендера вместо одного, между которыми пользователь теоретически успевает увидеть пустую строку.
Лечение в одну строку, вычисление прямо в рендере:
const fullName = firstName + ' ' + lastName;
Без состояния, без эффекта, без рассинхронизации. Дорогую фильтрацию больших списков в том же месте закрывает useMemo, а свежий React Compiler в перспективе снимает и эту заботу, мемоизируя автоматически. Общее правило из документации переводится просто: если значение можно посчитать из уже имеющегося, в состоянии его держать не надо, и эффект для пересчёта тоже.
Отдельная категория боли, эффекты-логгеры и сайд-эффекты ради "сделать что-то при изменении". Каждая попытка синхронизировать два куска состояния через эффект порождает змейку обновлений, в которой через месяц не разберётся и автор.
Устаревшее замыкание и интервал который помнит прошлое
Пустой массив зависимостей это обещание "запусти один раз". Обещание эффект держит, и именно в этом беда. Код внутри замыкания навсегда запоминает значения из первого рендера:
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // count навсегда равен нулю из первого рендера
}, 1000);
return () => clearInterval(id);
}, []);
Секундомер на этом коде отсчитывает вечную единицу: интервал честно складывает ноль и один раз в секунду. Разработчик, который читает документацию по диагонали, решает, что зависимость забыли, добавляет count в массив, и получает интервал, который пересоздаётся каждую секунду, сбрасывая таймер до нуля.
Оба решения мимо. Правильное в функциональной форме обновления:
setCount((prev) => prev + 1);
Замыкание больше не читает состояние, а просит React применить изменение к последнему значению. Для сложных сценариев, где чтение свежего состояния всё же нужно, применяют ref как почтовый ящик или готовятся к хуку useEffectEvent, который команда React долго вела к релизу, чтобы вынуть значения из списка зависимостей.
Для долгоживущих объектов, соединений и воркеров, где перезапуск по зависимостям невозможен, применяют ref как почтовый ящик для свежих значений:
const queryRef = useRef(query);
useEffect(() => {
queryRef.current = query;
}, [query]);
Долгоживущий код читает queryRef.current в любой момент и всегда получает последнее значение, не попадая в список зависимостей. Приём рабочий, но требующий дисциплины: реф это осознанный люк, а не способ спрятать зависимость от линтера.
Симптом устаревшего замыкания узнаваем: значение "залипает" на первом рендере, а в консоли нет ни ошибки, ни предупреждения. Линтер молчит, код работает, приложение врёт.
Гонки сетевых запросов и очистка через AbortController
Поисковая строка с запросом из useEffect это классика собеседований и классика продакшена. Пользователь быстро набирает слово из шести букв, летят шесть запросов, и ответы приезжают в сеть в произвольном порядке. Приезжает самый медленный, последний по времени отправки не последний по времени получения, и в результатах красуется ответ на "guit", хотя в строке уже "guitar".
Документация предлагает два инструмента. Первый, флажок-глушилка:
useEffect(() => {
let ignore = false;
fetch('/api/search?q=' + query)
.then((r) => r.json())
.then((json) => { if (!ignore) setResults(json); });
return () => { ignore = true; };
}, [query]);
Очистка старого эффекта ставит флажок, и опоздавший ответ просто выбрасывается. Второй инструмент жёстче и честнее, прерывание на уровне браузера:
useEffect(() => {
const controller = new AbortController();
fetch('/api/search?q=' + query, { signal: controller.signal });
return () => controller.abort();
}, [query]);
Сеть перестаёт тратить ресурсы на устаревшие запросы, а не только интерфейс на их игнорирование. Практика добавляет и третий слой, задержку отправки: классический debounce в 300 миллисекунд превращает шесть запросов на "guitar" в один, и никакая гонка физически не успевает начаться. Кто-то спросит, причём тут вообще массив зависимостей. Ответ прямой: очистка это оборотная сторона зависимостей, каждый повторный запуск обязан начинаться с уборки за прошлым.
StrictMode и двойной запуск эффектов как источник паники
Команда обновляет React до 18, ничего не меняет в коде, и в консоли разработки каждый эффект вдруг выполняется дважды. Монтирование, очистка, снова монтирование:
useEffect(() => {
console.log('подключились');
return () => console.log('отключились');
}, []);
В логе три строки вместо двух, дежурный подозревает поломку, а команда на спринт уходит в отключение StrictMode. Отключать можно, но это выбрасывание детектора проблемы вместо её решения. Двойной запуск в dev-режиме включён намеренно, чтобы обнажить эффекты без симметричной очистки: реальный пользователь уходит со страницы и возвращается, React в будущем сможет монтировать экран заново, и код обязан переживать это без потери подписок и накопления таймеров.
Правило здоровья эффекта, симметрия. Подписался, отписался. Создал интервал, очистил. Открыл соединение, закрыл. Эффект, который выдерживает цикл монтирование-очистка-монтирование, выдержит и StrictMode, и быструю навигацию, и уход в офлайн. На продакшене двойной запуск не происходит, это инструмент разработки, а не поведение релиза.
Когда эффект в принципе не нужен и где граница разумного
Формальное следование документации в реальном проекте заканчивается списком исключений, и полезно знать его наизусть:
- производные значения считаются прямо в рендере, без копирования в состояние и без эффекта;
- дорогие вычисления кэшируются через useMemo, а не пересчитываются эффектом по изменению входов;
- реакции на действия пользователя живут в обработчиках событий, где известно, что именно произошло;
- подписка на внешние хранилища и браузерные API делается через useSyncExternalStore, а не вручную;
- одиночные действия при монтировании, замеры и фокус, заслуживают эффекта, но с честной очисткой;
- чтение свежих значений без объявления их зависимостями лучше доверять рефам и будущему useEffectEvent, а не подавленному линтеру.
Командная практика закрывает остаток риска. На ревью у каждого эффекта задают два вопроса: с какой внешней системой он синхронизируется и что убирает его функция возврата. Нет ответа на первый, автору предлагают вычисление в рендере или обработчик события. Нет ответа на второй, замечание перерастает в задачу с меткой бага. Через месяц такой дисциплины новые эффекты в проекте пишутся симметричными по умолчанию.
Практический тест на необходимость эффекта звучит так: назовите внешнюю систему, с которой синхронизируется код. Сокет, таймер, DOM, сторонний виджет. Ответ найден, эффект оправдан. Ответа нет, и код просто перекладывает значения из состояния в состояние, тогда место ему в рендере или в обработчике.
Хук useEffect заслужил репутацию проблемного не своими свойствами, а привычкой решать им задачи, для которых он не строился. Документация права во всём, кроме одного: она описывает идеальный мир, а разработчик живёт в мире, где объект-пропс пересоздаётся чужим useMemo, ответы приезжают в обратном порядке, а StrictMode дублирует логи. Знание этих швов между правилами и практикой и отличает инженера от переписывателя примеров из справочника.