Состояние и эффекты: useState и useEffect без магии
useState и useEffect — два хука, на которых стоит почти всё в React. И именно вокруг них копится больше всего магического мышления: «почему эффект сработал два раза», «почему после setState значение старое», «зачем линтер требует эту зависимость, я же знаю, что делаю». Все эти вопросы растворяются, если принять одну ментальную модель, которую React-команда продвигает годами: рендер — это снимок состояния в конкретный момент, а эффект — это синхронизация с внешней системой. Разберём её до конца.
useState: снапшот-модель
Заголовок раздела «useState: снапшот-модель»Когда ты пишешь const [count, setCount] = useState(0), переменная count в теле компонента — это не «текущее живое значение», а значение конкретного рендера. Каждый рендер фиксирует свои пропсы и состояние, как кадр в плёнке — эта модель «состояния как снимка» подробно разобрана в State as a Snapshot. Вот почему такой код ведёт себя «странно»:
function Counter() { const [count, setCount] = useState(0);
const handleClick = () => { setCount(count + 1); // планирует рендер, где count = 1 console.log(count); // НО здесь count всё ещё 0 — из этого рендера };
return <button onClick={handleClick}>{count}</button>;}Сеттер не мутирует переменную, а ставит в очередь обновление. Компонент будет вызван заново, и в новом рендере count уже будет равен 1. Попытка прочитать «свежее» значение из старого рендера — архитектурно невозможна: переменная захвачена замыканием кадра.
Функциональные обновления
Заголовок раздела «Функциональные обновления»Когда новое состояние зависит от старого, всегда используй функциональную форму:
// ❌ Если increment вызовут дважды в одном событии — прибавится 1, а не 2const increment = () => setCount(count + 1);
// ✅ Каждое обновление применяется к результату предыдущегоconst increment = () => setCount((prev) => prev + 1);Здесь prev — не «старый снапшот», а актуальное значение из очереди обновлений, которую React выстраивает в порядке вызовов. Это единственный легальный способ увидеть «текущее» состояние до следующего рендера.
Ленивая инициализация
Заголовок раздела «Ленивая инициализация»Аргумент useState(initialValue) вычисляется на каждом рендере, даже если используется только при первом. Для дорогих вычислений (парсинг из localStorage, построение дефолтного дерева) передай функцию — React вызовет её один раз:
// ❌ JSON.parse на каждом рендереconst [settings, setSettings] = useState( JSON.parse(localStorage.getItem('settings') ?? '{}'),);
// ✅ вычисление только при монтированииconst [settings, setSettings] = useState<Settings>(() => JSON.parse(localStorage.getItem('settings') ?? '{}') as Settings,);Разница незаметна на примитивах, но на объектах с тяжёлым конструированием это десятки миллисекунд на каждый рендер.
Объекты и массивы: иммутабельность
Заголовок раздела «Объекты и массивы: иммутабельность»Сеттер не сливает объекты (в отличие от классового this.setState). Передавай новое значение целиком, создавая копии:
const [user, setUser] = useState({ name: 'Ann', tags: ['admin'] });
// ❌ Мутация: React не увидит изменения (тот же объект по ссылке)user.tags.push('editor');
// ✅ Новый объект с новым массивомsetUser((prev) => ({ ...prev, tags: [...prev.tags, 'editor'],}));React сравнивает старое и новое значение через Object.is. Та же ссылка — «ничего не изменилось», рендера не будет (даже если ты мутировал объект по пути). Это работает и наоборот: создаёшь новый объект с тем же содержимым — будет лишний рендер. Отсюда правило: не создавай новые ссылки без необходимости, особенно в пропсах и зависимостях эффектов.
useEffect: ментальная модель синхронизации
Заголовок раздела «useEffect: ментальная модель синхронизации»Самое полезное переосмысление из новой документации React: useEffect — это escape hatch для синхронизации React-мира с внешней системой. Внешние системы: сеть, DOM, таймеры, подписки, сторонние библиотеки, localStorage. Не внешние системы: преобразование одних данных в другие — это делается прямо в рендере.
// Эффект — потому что localStorage находится вне ReactuseEffect(() => { localStorage.setItem('theme', theme);}, [theme]);
// Не эффект — преобразование props в отображение. Делаем в рендере:const fullName = `${firstName} ${lastName}`;Эффект срабатывает после фазы commit, когда React уже применил изменения к DOM. Отсюда его идеальная роль: «сделать так, чтобы внешний мир соответствовал нашему состоянию». Не «выполнить код после рендера» — это старая lifecycle-модель, которая порождает лишние эффекты.
Зависимости: честный контракт
Заголовок раздела «Зависимости: честный контракт»Массив зависимостей — это не оптимизация и не «когда мне хочется». Это декларация: «эффект читает эти значения, поэтому при их изменении нужна повторная синхронизация». Если эффект использует userId, а userId не в зависимостях — синхронизация расходится с реальностью:
// ❌ Линтер прав: userId используется, но не указан.// При смене userId профиль не перезагрузится — баг молчаливый.useEffect(() => { fetch(`/api/users/${userId}`).then(setUser);}, []); // <-- умышленно пропустил, чтобы «не слать запросы»
// ✅ Зависимости честныеuseEffect(() => { fetch(`/api/users/${userId}`).then(setUser);}, [userId]);eslint-plugin-react-hooks с правилом exhaustive-deps знает значительно лучше тебя, когда эффект рассинхронирован. Подавлять правило через // eslint-disable-next-line — красный флаг в code review. Если линтер просит зависимость, а ты не хочешь перезапуска — это симптом неправильной архитектуры: либо значение надо положить в useRef, либо вынести логику в кастомный хук, либо пересмотреть, зачем эффект вообще.
Пропуск эффектов и очистка
Заголовок раздела «Пропуск эффектов и очистка»Очистка (cleanup) — функция, которую эффект возвращает. React вызывает её перед повторным запуском эффекта и при размонтировании. Её назначение — откатить предыдущую синхронизацию: отписаться, остановить таймер, отменить запрос.
useEffect(() => { const onResize = () => setWidth(window.innerWidth); window.addEventListener('resize', onResize);
// без очистки каждый рендер добавляет ещё один слушатель — утечка return () => window.removeEventListener('resize', onResize);}, []); // пустые зависимости = «синхронизировать один раз»Строгий режим в dev вызывает эффект → очистку → эффект специально, чтобы убедиться: твоя пара «подписаться/отписаться» симметрична. Если эффект пишет в базу или шлёт аналитику без защиты — двойной вызов покажет это сразу.
Пустой массив зависимостей
Заголовок раздела «Пустой массив зависимостей»[] означает «синхронизировать один раз при монтировании». Это корректно только если эффект вообще ничего не читает из пропсов и состояния — что редкость. Классика жанра: эффект с [], который использует userId из пропсов. Монтирование случается один раз, а пропсы могут смениться — синхронизация умерла.
Race conditions в эффектах
Заголовок раздела «Race conditions в эффектах»Самый частый и самый коварный баг в React-приложениях. Сценарий: пользователь быстро переключает вкладки/пользователей, эффект запускается заново, но предыдущий запрос ещё не завершился. Ответы приходят в произвольном порядке — и на экране данные от «прошлой» вкладки.
// ❌ Гонка: запрос за userId=2 может прийти позже запроса за userId=3useEffect(() => { fetch(`/api/users/${userId}`) .then((r) => r.json()) .then(setUser);}, [userId]);Решение №1 — флаг игнорирования. Просто, надёжно, работает везде:
useEffect(() => { let cancelled = false;
fetch(`/api/users/${userId}`) .then((r) => r.json()) .then((data) => { if (!cancelled) setUser(data); // старый запрос просто не применяем });
// очистка: эффект перезапустился (userId изменился) или компонент умер return () => { cancelled = true; };}, [userId]);Решение №2 — AbortController, штатный механизм отмены fetch. Плюс: реальный отменённый запрос освобождает сеть и сервер, а не просто игнорируется на клиенте:
useEffect(() => { const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal }) .then((r) => r.json()) .then(setUser) .catch((err) => { // AbortError — это НЕ ошибка, это ожидаемая отмена if (err.name !== 'AbortError') setError(err); });
return () => controller.abort(); // отменяем при смене userId или размонтировании}, [userId]);Когда эффект НЕ нужен: производные состояния
Заголовок раздела «Когда эффект НЕ нужен: производные состояния»Программисты, пришедшие из классовых lifecycle-методов, инстинктивно кладут в стейт всё, что «вычисляется», и обновляют через эффект. Это создаёт второй источник правды и класс багов «забыл обновить». Правило: если значение можно вычислить из пропсов/состояния во время рендера — вычисляй в рендере, без эффекта и без useState.
// ❌ Антипаттерн «эффект для вычисления»const [fullName, setFullName] = useState('');useEffect(() => { setFullName(`${firstName} ${lastName}`);}, [firstName, lastName]);
// ✅ Производное значение — прямо в рендереconst fullName = `${firstName} ${lastName}`;// ❌ Фильтрованный список в стейте + эффект для синхронизацииconst [filtered, setFiltered] = useState(items);useEffect(() => { setFiltered(items.filter((i) => i.name.includes(query)));}, [items, query]);
// ✅ Мемоизируем вычисление (если список большой)const filtered = useMemo( () => items.filter((i) => i.name.includes(query)), [items, query],);// ❌ «Состояние при изменении пропсов» через эффектconst [prevId, setPrevId] = useState(userId);if (prevId !== userId) { setPrevId(userId); setAvatar(null);}
// ✅ Ключевой трюк: reset через key — честнее и без эффекта// <UserCard key={userId} userId={userId} />Официальная статья «You Might Not Need an Effect» перечисляет четыре типичных замены: вычисления в рендере, обработка события в обработчике (а не в эффекте), сброс состояния через key, корректировка состояния при рендере через проверку предыдущего значения. Внутренний детектор: если эффект вызывает только сеттеры — он почти наверняка лишний.
Эффект против обработчика: где брать данные
Заголовок раздела «Эффект против обработчика: где брать данные»Тонкая, но важная граница: эффект — для реакции на синхронизацию состояния мира с рендером, обработчик — для реакции на действие пользователя. Классическая ошибка — грузить данные «при монтировании» через эффект, когда триггером является клик пользователя:
// ❌ Данные грузятся при монтировании + при каждой смене id эффектом.// А ведь инициировал загрузку пользователь кликом «Показать профиль»!function UserProfile({ userId }: { userId: string }) { const [user, setUser] = useState<User | null>(null); useEffect(() => { fetchUser(userId).then(setUser); }, [userId]); // ...}
// ✅ Загрузка — часть действия, а не эффектаfunction UserList() { const [user, setUser] = useState<User | null>(null);
const showProfile = async (id: string) => { // событие → действие → данные. Без эффекта, без гонки, // без лишнего рендера «пустого профиля». setUser(await fetchUser(id)); };
return ( <> <UserTable onSelect={showProfile} /> {user && <UserCard user={user} />} </> );}Когда эффект оправдан для данных? Когда источником является состояние мира, а не действие: текущий маршрут изменился, пропс userId пришёл извне, приложение вернулось в фокус. Всё, что пользователь сделал — территория обработчика.
Дебаунс в эффектах: таймер как внешняя система
Заголовок раздела «Дебаунс в эффектах: таймер как внешняя система»Дебаунс — каноничный случай «эффект управляет внешней системой» (таймером), и здесь видна вся механика: создание, очистка, зависимости:
useEffect(() => { if (!query) { setResults([]); return; } // таймер — внешняя система: создаём, очищаем при перезапуске/размонтировании const timer = setTimeout(() => { searchApi(query).then(setResults); }, 300);
return () => clearTimeout(timer);}, [query]); // каждый новый символ отменяет предыдущий таймерТри детали, которые делают этот эффект правильным: таймер очищается (никаких «залипших» вызовов после ухода со страницы), запрос не стартует на пустой запрос (условие внутри эффекта, а не снаружи — иначе получишь эффект-«призрак»), зависимость только query. В проде за этим наблюдает ещё и AbortController — дебаунс не отменяет уже ушедший запрос, только откладывает старт. Пара «дебаунс + abort» закрывает оба конца.
Вынос эффектов в кастомные хуки
Заголовок раздела «Вынос эффектов в кастомные хуки»Эффекты размножаются по компонентам быстро: подписка на online, синхронизация с localStorage, таймеры, document.title. Каждый — 5–10 строк с требовательным к чистоте cleanup’ом. Эталонное решение — кастомный хук (подробности в главе 4), здесь важно одно: эффект — это деталь имплементации хука, а не компонента. Компонент декларирует «мне нужно знать ширину окна», а не «мне нужно повесить слушатель resize с setState». Такая инкапсуляция убирает дублирование cleanup-логики и делает эффекты тестируемыми через renderHook.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»setStateпрямо в теле компонента — бесконечный цикл рендеров (каждый сеттер запускает рендер, рендер снова сеттит). Либо ленивая инициализация, либо обработчик, либо эффект.- Умышленно пустые зависимости с использованием пропсов. «Хочу запрос только при монтировании» + чтение пропсов = рассинхрон. Линтер об этом кричит — слушай его.
- Отсутствие cleanup в подписках. Без возврата функции отписки каждый перезапуск эффекта (и StrictMode в dev) накапливает слушателей. Симптомы: эффект «срабатывает много раз», растёт память.
- Обработка AbortError как ошибки. Отменённый fetch кидает исключение с
name === 'AbortError'— его нужно отфильтровывать, иначе при каждой смене вкладки будет вспышка ошибки. - Мутация состояния напрямую (
arr.push,obj.field = x). React сравнивает ссылки — мутация незаметна, рендера нет. Лечится дисциплиной иммутабельных обновлений (или Immer, если объекты глубокие). - Эффект для данных, которые можно вычислить в рендере. Лишний стейт — лишний рендер и расходящиеся источники правды. Фильтры, сортировки, объединённые строки — считай в рендере.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»Почему после вызова setState значение переменной не меняется? Сеттер ставит обновление в очередь и планирует рендер. Текущий рендер — это снапшот: его переменные зафиксированы замыканием. Новое значение появится только в следующем рендере. Для цепочек — функциональные обновления.
Когда нужна функциональная форма setState?
Всегда, когда новое состояние зависит от предыдущего. Форма setCount(count + 1) использует устаревший снапшот при нескольких обновлениях в одном событии; форма (prev) => prev + 1 гарантированно применяет очередь последовательно.
Зачем линтер требует все зависимости в useEffect? Эффект — это синхронизация; зависимости описывают, что он читает. Пропущенная зависимость означает, что при её изменении синхронизация не произойдёт — молчаливый баг, который воспроизводится только при определённой последовательности действий пользователя.
Как избежать race condition в эффекте с fetch?
Два штатных способа: флаг cancelled с проверкой перед setState в then, либо AbortController, который передаётся в fetch и чей abort() вызывается в cleanup. Второй предпочтительнее — реально отменяет запрос.
Что такое cleanup и когда выполняется? Функция, возвращённая эффектом. React вызывает её перед повторным запуском эффекта и при размонтировании компонента. Назначение — симметричный откат: отписка, clearInterval, abort.
Когда useEffect не нужен?
Если значение выводится из пропсов/состояния — считай в рендере (с useMemo при необходимости). Если действие — реакция на событие — делай в обработчике. Если нужно сбросить состояние — используй key. Эффект только для внешних систем: сеть, DOM, подписки, таймеры.
Почему эффект срабатывает дважды в StrictMode? Это намеренная проверка симметрии «подписаться/отписаться» и отсутствия деструктивных сайд-эффектов. Работающий эффект переживает двойной вызов без последствий; сломанный показывает проблему сразу в dev.
Практика
Заголовок раздела «Практика»- Гонка на живую. Сделай компонент с выбором пользователя из списка (клик → эффект → fetch профиля). Добавь искусственную задержку на сервере, кликай быстро. Зафиксируй рассинхрон. Исправь через
AbortController. - Ленивая инициализация. Замерь время рендера компонента с
useState(heavyParse())иuseState(() => heavyParse())(поставь тяжёлый JSON-парсинг в цикле). Убедись в разнице через performance.now(). - «Найди лишний эффект». Возьми любой свой рабочий код (или пример из документации) и прогони чек-лист: эффект вызывает только сеттеры? значение выводимо в рендере? Перепиши два таких случая без эффектов.
- Симметрия подписки. Напиши эффект, подписывающийся на
window.addEventListener('online'), с индикатором статуса в шапке. Проверь под StrictMode, что нет двойных срабатываний после многократных монтирований. - Ключ вместо эффекта. Реализуй экран чата: при смене собеседника должен очищаться черновик сообщения. Сначала решай через эффект сброса, потом через
key={chatId}на компоненте ввода. Сравни читаемость.
Что почитать
Заголовок раздела «Что почитать»- React Dev: You Might Not Need an Effect — главная статья о производных состояниях; обязательна к прочтению дважды.
- React Dev: Synchronizing with Effects — каноничная ментальная модель эффектов как синхронизации.
- React Dev: State as a Snapshot — почему состояние — это снимок, а не переменная.
- React Dev: Queueing a Series of State Updates — механика очереди обновлений и функциональной формы.
- MDN: AbortController — спецификация отмены fetch и других асинхронных операций.