Перейти к содержимому

Состояние и эффекты: useState и useEffect без магии

useState и useEffect — два хука, на которых стоит почти всё в React. И именно вокруг них копится больше всего магического мышления: «почему эффект сработал два раза», «почему после setState значение старое», «зачем линтер требует эту зависимость, я же знаю, что делаю». Все эти вопросы растворяются, если принять одну ментальную модель, которую React-команда продвигает годами: рендер — это снимок состояния в конкретный момент, а эффект — это синхронизация с внешней системой. Разберём её до конца.

Когда ты пишешь 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, а не 2
const 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. Та же ссылка — «ничего не изменилось», рендера не будет (даже если ты мутировал объект по пути). Это работает и наоборот: создаёшь новый объект с тем же содержимым — будет лишний рендер. Отсюда правило: не создавай новые ссылки без необходимости, особенно в пропсах и зависимостях эффектов.

Самое полезное переосмысление из новой документации React: useEffect — это escape hatch для синхронизации React-мира с внешней системой. Внешние системы: сеть, DOM, таймеры, подписки, сторонние библиотеки, localStorage. Не внешние системы: преобразование одних данных в другие — это делается прямо в рендере.

// Эффект — потому что localStorage находится вне React
useEffect(() => {
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 из пропсов. Монтирование случается один раз, а пропсы могут смениться — синхронизация умерла.

Самый частый и самый коварный баг в React-приложениях. Сценарий: пользователь быстро переключает вкладки/пользователей, эффект запускается заново, но предыдущий запрос ещё не завершился. Ответы приходят в произвольном порядке — и на экране данные от «прошлой» вкладки.

// ❌ Гонка: запрос за userId=2 может прийти позже запроса за userId=3
useEffect(() => {
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.

  1. setState прямо в теле компонента — бесконечный цикл рендеров (каждый сеттер запускает рендер, рендер снова сеттит). Либо ленивая инициализация, либо обработчик, либо эффект.
  2. Умышленно пустые зависимости с использованием пропсов. «Хочу запрос только при монтировании» + чтение пропсов = рассинхрон. Линтер об этом кричит — слушай его.
  3. Отсутствие cleanup в подписках. Без возврата функции отписки каждый перезапуск эффекта (и StrictMode в dev) накапливает слушателей. Симптомы: эффект «срабатывает много раз», растёт память.
  4. Обработка AbortError как ошибки. Отменённый fetch кидает исключение с name === 'AbortError' — его нужно отфильтровывать, иначе при каждой смене вкладки будет вспышка ошибки.
  5. Мутация состояния напрямую (arr.push, obj.field = x). React сравнивает ссылки — мутация незаметна, рендера нет. Лечится дисциплиной иммутабельных обновлений (или Immer, если объекты глубокие).
  6. Эффект для данных, которые можно вычислить в рендере. Лишний стейт — лишний рендер и расходящиеся источники правды. Фильтры, сортировки, объединённые строки — считай в рендере.

Почему после вызова 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.

  1. Гонка на живую. Сделай компонент с выбором пользователя из списка (клик → эффект → fetch профиля). Добавь искусственную задержку на сервере, кликай быстро. Зафиксируй рассинхрон. Исправь через AbortController.
  2. Ленивая инициализация. Замерь время рендера компонента с useState(heavyParse()) и useState(() => heavyParse()) (поставь тяжёлый JSON-парсинг в цикле). Убедись в разнице через performance.now().
  3. «Найди лишний эффект». Возьми любой свой рабочий код (или пример из документации) и прогони чек-лист: эффект вызывает только сеттеры? значение выводимо в рендере? Перепиши два таких случая без эффектов.
  4. Симметрия подписки. Напиши эффект, подписывающийся на window.addEventListener('online'), с индикатором статуса в шапке. Проверь под StrictMode, что нет двойных срабатываний после многократных монтирований.
  5. Ключ вместо эффекта. Реализуй экран чата: при смене собеседника должен очищаться черновик сообщения. Сначала решай через эффект сброса, потом через key={chatId} на компоненте ввода. Сравни читаемость.