useState: батчинг и функциональное обновление
Классический вопрос первой пятиминутки: три вызова setCount подряд, а счётчик вырос на единицу. Разбираемся почему — и что при этом печатает console.log.
Состояние в React — это не переменная, которую можно менять, а снимок: значение, зафиксированное на время одного рендера.
setStateне правит снимок, а планирует следующий.
Вопрос: что окажется на кнопке и что попадёт в консоль?
Интервьюер показывает компонент и просит ответить на два вопроса: какое число будет на кнопке после одного клика и что напечатает console.log.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log('count внутри обработчика:', count);
}
return <button onClick={handleClick}>{count}</button>;
}
Правильный ответ: на кнопке будет 1, а в консоли — 0. Причём это не «баг React» и не «асинхронность», как часто отвечают. Это прямое следствие того, как устроен рендер.
Состояние — это снимок, а не ячейка памяти
Когда React вызывает Counter(), он выполняет тело функции сверху вниз. Строка const [count, setCount] = useState(0) отдаёт значение на этот рендер — ноль. Дальше count — обычная константа внутри замыкания. Обработчик handleClick создан в этом же рендере и захватил именно эту константу.
Поэтому setCount(count + 1) три раза подряд — это буквально setCount(0 + 1), setCount(0 + 1), setCount(0 + 1). Три раза «поставь единицу». Результат — единица. И console.log(count) тоже печатает ноль: переменная в замыкании физически не может измениться после того, как функция была создана. Новое значение появится только в новом рендере — в новом вызове функции-компонента, с новым замыканием.
Отсюда фраза, которую стоит произнести на собеседовании дословно: «между рендерами состояние не мутируется — каждый рендер видит свой замороженный снимок». Это заодно объясняет, почему setTimeout(() => console.log(count), 3000) напечатает старое значение, даже если за три секунды вы прокликали кнопку десять раз. Это называется устаревшим замыканием (stale closure).
Батчинг: почему рендер один, а вызовов три
Второй слой ответа — батчинг. React не перерисовывает компонент после каждого setState. Он складывает обновления в очередь и обрабатывает её один раз, когда обработчик события целиком отработал. Три вызова — один рендер. Это осознанная оптимизация: иначе форма с пятью полями рисовалась бы пять раз подряд.
Важная деталь, которую любят спрашивать: до React 18 батчинг работал только внутри обработчиков событий React. Внутри setTimeout, fetch().then() или нативного слушателя каждый setState вызывал отдельный рендер. С React 18 (при монтировании через createRoot) появился автоматический батчинг — обновления группируются везде. Если батчинг нужно принудительно разорвать (редкий случай — измерить DOM между обновлениями), есть flushSync из react-dom.
Функциональное обновление: setCount(prev => prev + 1)
Если передать в сеттер не значение, а функцию, React положит в очередь саму функцию и при обработке очереди применит её к накопленному результату, а не к снимку из замыкания.
function handleClick() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
}
// теперь счётчик вырастет на 3 за один рендер
Разница между двумя формами — ровно в том, откуда берётся база для сложения: из замыкания прошлого рендера или из очереди.
Как это работает под капотом
Обработка очереди в React — это, по сути, обычный reduce. Для каждого хука состояния хранится список отложенных обновлений; React берёт базовое значение и применяет их по порядку: функцию — вызывает, обычное значение — просто подставляет. Этот алгоритм можно воспроизвести на чистом JavaScript, и он объясняет разницу нагляднее любых слов.
function processQueue(baseState, queue) {
let state = baseState;
for (const update of queue) {
state = typeof update === 'function' ? update(state) : update;
}
return state;
}
const count = 0; // снимок из текущего рендера
// три вызова setCount(count + 1) — в очередь легли ЗНАЧЕНИЯ
console.log('setCount(count + 1) x3 →', processQueue(count, [count + 1, count + 1, count + 1]));
// три вызова setCount(prev => prev + 1) — в очередь легли ФУНКЦИИ
console.log('setCount(prev => prev + 1) x3 →', processQueue(count, [p => p + 1, p => p + 1, p => p + 1]));
Результат:
setCount(count + 1) x3 → 1
setCount(prev => prev + 1) x3 → 3
Ещё одна вещь, о которой полезно упомянуть: React сравнивает новое состояние со старым через Object.is и, если они совпали, может вообще пропустить рендер (bail out). Отсюда классическая беда с объектами: если вы напишете user.name = 'Аня'; setUser(user), ссылка не изменилась — Object.is вернёт true, и экран не обновится. Правильно — создавать новый объект: setUser({ ...user, name: 'Аня' }).
| Код в обработчике | Счётчик после клика (был 0) |
setCount(count + 1) ×3 | 1 — трижды «поставь 1» |
setCount(prev => prev + 1) ×3 | 3 — каждая функция видит накопленное |
setCount(count + 1), затем setCount(prev => prev + 1) | 2 — сначала 1, потом +1 поверх |
setCount(5), затем setCount(prev => prev + 1) | 6 |
Частые ошибки на собеседовании
- «setState асинхронный, поэтому и получается 1». Полуправда, которая не объясняет, почему функциональная форма даёт 3. Ответ про снимок и очередь — сильный, ответ про «асинхронность» — слабый.
- «Значение обновится сразу после вызова сеттера». Нет:
countв текущем замыкании останется прежним до следующего рендера. Если нужно новое значение прямо в обработчике — посчитайте его вручную:const next = count + 1. - «Функциональная форма нужна всегда». Нет. Она нужна там, где новое состояние зависит от предыдущего, а также в асинхронном коде (таймеры,
await, подписки), где замыкание успело устареть. - Мутация объекта или массива в состоянии (
items.push(x); setItems(items)) — ссылка та же, рендера не будет. Нужен новый массив:setItems([...items, x]). - Забыть про React 18. Вопрос «а батчатся ли обновления внутри
setTimeout?» — проверка, следите ли вы за версиями. С 18-й — да, автоматически.
Итоги-шпаргалка
- Состояние — снимок рендера. Внутри одного рендера
countне меняется никогда. setStateне присваивает, а кладёт обновление в очередь и планирует рендер.- Батчинг: несколько обновлений подряд → один рендер. С React 18 — везде, не только в обработчиках событий.
- Очередь обрабатывается как
reduce: значение заменяет аккумулятор, функция — преобразует его. setX(prev => ...)— когда новое состояние зависит от старого или код асинхронный.- Объекты и массивы обновляем иммутабельно: React сравнивает ссылки через
Object.is.