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) ×31 — трижды «поставь 1»
setCount(prev => prev + 1) ×33 — каждая функция видит накопленное
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.
Проверьте себя
1. Состояние count равно 0. В обработчике клика выполняется: setCount(count + 1); setCount(count + 1); setCount(prev => prev + 1). Чему будет равен count после клика?
A1
B2
C3
D0
2. Почему console.log(count) сразу после setCount(count + 1) печатает старое значение?
AПотому что console.log выполняется асинхронно
BПотому что count — константа текущего рендера, а новое значение появится только в следующем рендере
CПотому что React батчит только console.log
DПотому что setCount возвращает Promise, который нужно дождаться