setState — это запрос: снимки, очереди обновлений и автоматический батчинг
setState — это запрос в очередь на fiber, а не присваивание: state — снимок на рендер, поэтому два setCount(count+1) дают один инкремент. React 18 батчит везде — промисы, таймауты, нативные обработчики — один рендер на батч; flushSync форсирует синхронный сброс за реальную цену.
Команда лояльности нашла это при сверке леджера: 4 112 погашений, где бэкенд списал у пользователей баллы за бандл, а UI — и следующий запрос, построенный из состояния UI, — зафиксировал только меньшее списание. Код дважды прошёл ревью. Обработчик комбо-погашения делал очевидное: setPoints(points - cost) за базовую позицию, а когда применялся бонус — setPoints(points - bonusCost) строкой ниже. Каждый инженер, читавший код, видел два вычитания. React видел два запроса на замену состояния, оба вычисленные от одного снимка: points в обеих строках был равен 500, так что в очереди лежали «заменить на 400» и «заменить на 450» — и второй молча победил. Одиночные погашения работали, QA тестировал одиночные погашения, и комбо-путь уехал в прод. Исправление — четыре символа на строку: setPoints(p => p - cost). Но команда поняла его только после того, как кто-то объяснил: состояние в React — не переменная, в которую пишут; это снимок, из которого читают, а setState — сообщение следующему рендеру, а не присваивание текущему.
State — снимок; setState — запрос
Если ты когда-нибудь вызывал setState дважды и видел только одно обновление — причина здесь. Рендер работает с замороженной копией состояния. Когда React вызывает компонент, useState возвращает значения на момент этого рендера, и каждое созданное в нём замыкание — включая обработчики событий — захватывает эти значения навсегда. Вызов setPoints не меняет points; он и не может: points — это const в вызове функции, который уже завершился или вот-вот завершится. Что setPoints(next) делает на самом деле — добавляет обновление в очередь на fiber этого хука и планирует ре-рендер. Лишь когда следующий рендер выполнится, React свернёт очередь и выдаст компоненту новое значение.
Это весь механизм за самым частым вопросом собеседований по React. В обработчике, где count равен 0, три вызова setCount(count + 1) ставят в очередь «заменить на 1» трижды — каждая строка читала один и тот же снимок. Следующий рендер сворачивает очередь: 1, 1, 1. Итог: 1, один рендер. Ментальная модель «setState — это медленное/асинхронное присваивание» неверна важным образом: это не отложенная запись, это вообще не запись. Это запрос на будущий рендер, вычисленный из того, что вы передали — а передали вы то, что посчитали от снимка, устаревшего по построению.
Updater-функции: отправляем инструкции, а не значения
Очередь принимает два вида записей: значение-замену или updater-функцию. setPoints(p => p - cost) ставит в очередь саму функцию; на следующем рендере React сворачивает очередь по порядку, передавая каждому updater-у результат предыдущей записи. Это делает последовательность updater-ов композируемой там, где значения-замены — нет:
function Redeem({ cost, bonusCost }) {
const [points, setPoints] = useState(500);
function redeemCombo() {
// Баг: обе строки читают один снимок (points = 500).
// Очередь: [replace 400, replace 450] — побеждает второй. Результат: 450.
setPoints(points - cost); // cost = 100
setPoints(points - bonusCost); // bonusCost = 50
}
function redeemComboFixed() {
// Очередь: [p => p - 100, p => p - 50], сворачивается по порядку:
// 500 -> 400 -> 350. Результат: 350, и всё равно ровно один рендер.
setPoints((p) => p - cost);
setPoints((p) => p - bonusCost);
}
}Смешанные формы сворачиваются по тому же правилу: при count равном 0 последовательность setCount(c => c + 1), setCount(42), setCount(c => c + 1) даёт 0 → 1 → 42 → 43. Замена отбрасывает всё, что было до неё; updater-ы строятся на том, что им предшествует. Та же логика снимков объясняет протухшие замыкания вне обработчиков: интервал, созданный при монтаже, навсегда захватил count первого рендера, и setCount(count + 1) внутри него прибивает значение к 1 — а setCount(c => c + 1) читает живое состояние сворачиваемой очереди и считает правильно. Отсюда правило: любое обновление, выведенное из предыдущего состояния, обязано использовать updater-форму. Это не вопрос стиля; это единственная форма, которую очередь умеет композировать.
▸Почему это работает
Почему React вообще выбрал семантику снимков, а не немедленную запись setState, чтобы следующая строка читала новое значение? Потому что немедленная запись рвала бы рендер. Ваш JSX, обработчики и эффекты одного рендера обязаны описывать один консистентный момент — если бы состояние мутировало посреди рендера, две части одного экрана могли бы отрендериться от разных значений: ровно тот класс «рваного UI», от которого защищает атомарный commit. Снимки к тому же делают обработчики предсказуемыми при асинхронных паузах: alert, показанный через три секунды после клика, сообщает значения на момент клика, а не то, во что мир мутировал с тех пор. Конструкция «очередь плюс updater» — цена этой консистентности, и она же делает батчинг безопасным: React знает, что каждое отложенное обновление — это либо значение, либо чистая функция, которые можно свернуть на рендере.
Автоматический батчинг: что на самом деле изменил React 18
Батчинг — это схлопывание нескольких вызовов setState в один рендер и один commit. React 17 батчил только внутри React-обработчиков событий — в стеке синтетических событий, который он контролировал. Как только код покидал этот стек, батчинг заканчивался: в .then промиса, в setTimeout, в нативном addEventListener каждый setState запускал собственный синхронный проход render-и-commit. Три setState в колбэке fetch — три полных рендера подряд; и код в дикой природе молча на это полагался, читая свежезакоммиченный DOM между двумя строками setState.
React 18 с createRoot сделал батчинг автоматическим везде: обработчики, промисы, таймауты, нативные события — все обновления одного тика встают в очередь и сбрасываются одним рендером. Прирост производительности механический: колбэк fetch, выставляющий loading, данные и пагинацию, прошёл путь от 3 проходов render+commit до 1 — на тяжёлой странице это разница между одним кадром в 40 мс и тремя. Сценарий поломки при миграции — обратная сторона: код, полагавшийся на flush-на-каждый-вызов из React 17 — измерить DOM после setState в таймауте, тест, проверяющий промежуточные рендеры, — теперь читает устаревший DOM, потому что commit происходит после завершения всего колбэка. Обновления внутри transition батчатся отдельно от срочных, но внутри каждой полосы правило то же: один сброс на батч.
flushSync: запасной выход и его счёт
Иногда DOM действительно нужен обновлённым сейчас, посреди обработчика: добавить сообщение в чат и проскроллить список к нему, сфокусировать input, который создаст следующий рендер, заполнить состояние до того, как диалог печати браузера сделает снимок страницы. flushSync(fn) выполняет обновления внутри fn и синхронно форсирует render и commit до возврата — следующая строка обработчика видит закоммиченный DOM.
import { flushSync } from "react-dom";
flushSync(() => {
setMessages((prev) => [...prev, newMessage]);
});
// DOM закоммичен: новый <li> существует, scroll попадает в реальный узел.
listRef.current.lastChild.scrollIntoView({ behavior: "smooth" });Счёт — ровно то, что батчинг вам экономил: каждый flushSync — полный синхронный render+commit в главном потоке, внутри вашего обработчика. Три вызова flushSync воссоздают худший случай React 17, только теперь намеренно. Он отключает батчинг для этих обновлений, блокирует обработчик до конца commit, форсирует layout раньше, чем сделал бы браузер, и может синхронно выполнить эффекты. Эвристика сеньора: flushSync — для императивных чтений DOM, обязанных видеть новое состояние — скролл, фокус, измерение, печать — и ни для чего больше. Если вы тянетесь к нему, чтобы «починить» устаревшее значение в собственной JavaScript-логике, настоящее лечение — updater-функция или локальное вычисление значения; flushSync там — пластырь за 40 мс поверх бага в четыре символа.
count равен 0. Обработчик клика выполняет: setCount(c => c + 1); setCount(42); setCount(c => c + 1). Каким будет count на следующем рендере и сколько рендеров произойдёт?
После апгрейда на React 18 с createRoot колбэк fetch .then выставляет три куска состояния и сразу измеряет высоту DOM-элемента. Измерение теперь неверное. Что изменилось?
- 01Почему два подряд setCount(count + 1) инкрементируют один раз, и что именно меняет updater-форма в сворачивании очереди?
- 02Что именно изменил автоматический батчинг в React 18, какой продакшен-код ломается на апгрейде и когда flushSync — правильный инструмент, а когда пластырь?
Состояние React — снимок, а не переменная: каждый рендер получает замороженные значения, каждое замыкание этого рендера захватывает их навсегда, и setState ничего не меняет в текущем рендере — он добавляет запись в очередь обновлений на fiber хука и планирует ре-рендер. Поэтому два вызова setCount(count + 1) ставят в очередь одно и то же значение-замену дважды и инкрементируют один раз — именно так UI программы лояльности недосписывал баллы на каждом комбо-погашении, дважды пройдя ревью. Очередь хранит значения-замены и updater-функции (функции-обновители) и сворачивается строго по порядку на рендере: замены отбрасывают всё до себя, updater-ы получают результат предыдущей записи и композируются — 0, затем updater, 42, updater даёт 43. Любое обновление, выведенное из предыдущего состояния, обязано использовать updater-форму; она же лечит протухшие замыкания в интервалах и асинхронных колбэках. Батчинг схлопывает все обновления одного тика в единственный рендер и commit: React 17 делал это только внутри React-обработчиков и сбрасывал синхронно на каждый вызов везде ещё, а React 18 с createRoot батчит и промисы, и таймауты, и нативные слушатели — три коммита становятся одним, и жертвы апгрейда — ровно тот код, который читал DOM между промежуточными сбросами. flushSync — явный выход: форсирует синхронные render и commit, чтобы следующая строка могла скроллить, фокусировать, измерять или печатать по новому DOM — ценой всего, что купил батчинг; затыкать им устаревшее значение в обычной логике вместо updater-функции — значит платить рендером за избегание исправления в четыре символа. Теперь, когда встретишь два setState, которые должны складываться, но применяется только последний — тянешься за updater-формой; а когда чтение DOM даёт устаревшее значение после апгрейда на React 18 — знаешь, какой выход открывать.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.