Состояние — снимок, а не переменная

состояние≈ 9 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Кнопка вызывает setCount(count + 1) три раза подряд — а счёт прибавляется на единицу. React ничего не потерял: count внутри обработчика не переменная, а снимок — застывшее значение той отрисовки, в которой обработчик родился. Разобравшись с этим один раз, вы перестанете удивляться половине странностей React.

Разделы темы:·

Компонент ниже за одно нажатие трижды просит увеличить счёт на единицу. Прежде чем запускать, ответьте на два вопроса: какое число окажется на экране после клика и сколько строк напечатает console.log из тела компонента.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На экране единица, а отрисовок две: с нулём и с единицей. Три вызова setCount не выстроились в лесенку 0 → 1 → 2 → 3, потому что все три прочитали одно и то же count, равное нулю, и трижды передали React одинаковую просьбу: «в следующей отрисовке пусть будет 1».

setCount(count + 1) — это не «прибавь единицу», а «поставь вот это число». Число вычисляется до вызова, из снимка.

Убедимся, что дело именно в чтении. Что напечатает console.log, стоящий строкой ниже setCount?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

setCount не присваивание. Он не трогает count в текущем обработчике — он планирует, каким count будет в следующей отрисовке. До неё переменная останется нулём, сколько бы раз вы её ни перечитали.

Компонент — обычная функция. React вызывает её, получает описание разметки и рисует. Захотел показать новое состояние — вызывает заново. Каждый такой вызов создаёт свои локальные переменные и свои обработчики, замкнувшие эти переменные. Всё, что вы знаете про замыкания из части I, работает здесь без единой поправки.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Это буквально фабрика функций из статьи про замыкания: два вызова render — два независимых окружения, и first уже не догонит second. React устроен так же, только render вызываете не вы из своего кода, а он сам — на каждое изменение состояния.

У этого есть заметное следствие в типах. Присваивание в count компилятор отверг ещё в статье «useState: память компонента» — там речь шла о том, что память лежит у React. Теперь у запрета есть вторая, более точная причина; заодно посмотрим, чем на присваивание отвечает браузер.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор называет вещи своими именами: «Не удается задать значение для count, так как это константа». А после запуска браузер добавляет своё — TypeError, точный текст которого у каждого движка свой. Слово const здесь не стилевая придирка: count и правда константа — константа этой отрисовки.

Отсюда и название: снимок. Значение зафиксировано в момент вызова компонента и до конца этой отрисовки не меняется ничем.

Пока обработчик отрабатывает мгновенно, разницу между снимком и живым значением заметить трудно. Она вылезает, как только между кликом и чтением проходит время: таймер, запрос, анимация.

Ниже кнопка «Отправить отчёт» ставит таймер на секунду, а кнопка «+1» успевает нажаться раньше, чем таймер сработает. Какое число попадёт в отчёт?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На экране единица, в отчёте ноль. Стрелка, отданная в setTimeout, родилась в отрисовке с нулём и замкнула её count. Последующие отрисовки создали новые стрелки с новыми значениями, но у нашей в таймере лежит старая — и никто её не подменит.

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

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

Чего делать точно не стоит — заводить рядом обычную переменную модуля и держать в ней «настоящий» счёт. Разметка перестанет соответствовать состоянию, и вы вернётесь ровно к тем ошибкам, от которых уходили в части II.

В части II состояние доски задач было обычным объектом, и обработчик читал его самое свежее поле — никаких снимков.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Удобно — ровно до тех пор, пока разметку собирает одна функция за один заход. Как только сборка разметки растягивается (а React умеет прерывать её, чтобы не задерживать ввод), живая переменная становится опасной: начало разметки соберётся из старого значения, конец — из нового, и на экране окажется состояние, которого никогда не существовало.

Снимок эту возможность закрывает. Пока идёт одна отрисовка, count в ней один и тот же — в заголовке, в списке, в подписи кнопки и во всех обработчиках, которые эта отрисовка создала. Разметка становится честной функцией от состояния: одно значение на входе — одна картинка на выходе.

Живая переменная (часть II)Снимок (React)
Что читает обработчикзначение на момент вызовазначение на момент своей отрисовки
Разметка одной перерисовкиможет смешать два состоянияцеликом собрана из одного значения
Чтобы понять код обработчиканужно знать всю историю измененийдостаточно знать одно число
Два обработчика одной отрисовкимогут увидеть разноевидят одинаковое

Цена — та самая неожиданность из первого раздела. React честно её платит: лучше один раз объяснить, почему +3 прибавил единицу, чем всю жизнь ловить разметку, склеенную из двух состояний.

Первая привычка: если следующее значение нужно и состоянию, и чему-то ещё — посчитайте его один раз в локальную переменную и дальше пользуйтесь ей, а не count.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В выводе видно ритм работы React: отрисовка со снимком, обработчик, который этот снимок читает, и новая отрисовка со следующим числом. Два клика дали 6 — потому что второй клик обрабатывал уже обработчик второй отрисовки, и его снимок равнялся тройке.

Вторая привычка: не перечитывайте count после setCount в надежде увидеть новое. Не увидите — и это не «иногда не успевает», а всегда. Нужен результат — берите его из той переменной, которую вы только что вычислили.

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

Снимок объясняет и другую загадку: почему обработчик из старой отрисовки не «протухает» и не ломается. Он просто честно делает то, о чём его просили тогда.

  • count из useState — не переменная, а константа отрисовки: снимок значения на момент вызова компонента.
  • setCount не присваивает, а планирует следующую отрисовку, поэтому сразу после него count остаётся прежним.
  • Три setCount(count + 1) подряд дают +1: все три читают один снимок и просят поставить одно и то же число.
  • Обработчики и колбэки замыкают снимок своей отрисовки — таймер напечатает то, что было в момент клика, а не то, что на экране.
  • Снимок нужен ради согласованности: вся разметка одной отрисовки собирается из одного значения.
  • Нужен результат нового состояния прямо в обработчике — вычислите его в локальную переменную; нужны шаги друг от друга — понадобится функция-обновитель.

1/4В обработчике клика стоят три вызова подряд: setCount(count + 1); setCount(count + 1); setCount(count + 1). Состояние было 0. Что окажется на экране после одного клика?

2/4Обработчик кнопки делает setTimeout(() => console.log(count), 1000). Мы нажали эту кнопку при count = 0, а через полсекунды нажали «+1». Что напечатает таймер?

3/4В обработчике написано setCount(count + 1), а следующей строкой console.log(count). Состояние было 5. Что попадёт в консоль?

4/4Почему компилятор на строку count = count + 1 внутри обработчика отвечает «Не удается задать значение для count, так как это константа»?

Кнопка, которая прибавляет два

Кнопка должна прибавлять сразу два шага, но прибавляет один. Почините её, не пользуясь функцией-обновителем — только тем, что разобрано в этой статье.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Оба вызова читают один и тот же снимок, поэтому второй ничего не добавляет. Считаем нужное значение один раз и просим поставить именно его.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Так обработчик перестаёт делать вид, что состояние меняется по шагам: он вычисляет конечное число из своего снимка и передаёт его целиком. Пока шаг известен заранее, этого достаточно; когда шагов несколько и каждый зависит от предыдущего, понадобится функция-обновитель из следующей статьи.

Что напечатает пример

Напишите ожидаемый вывод целиком — все четыре строки и в каком порядке они появятся, — и только потом запускайте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Порядок такой:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первая строка — начальная отрисовка. Клик попадает в обработчик, созданный ею, поэтому n в нём равно нулю: setN планирует новое значение, но своей константе ничего не делает. Дальше React вызывает компонент заново — уже с десяткой. И только потом срабатывает таймер, поставленный обработчиком первой отрисовки: он держит её замыкание, а там по-прежнему ноль.

Обратите внимание: на кнопке к этому моменту нарисовано 10, а таймер печатает 0. Экран и старое замыкание живут в разных отрисовках — это нормально.

Сумма, отставшая на один товар

Кнопка добавляет товар в корзину и печатает будущую сумму к оплате. Сумма всё время отстаёт на одну позицию. Исправьте обработчик.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

count * PRICE в обработчике считается по снимку — по тому количеству, которое было до клика. Нужно посчитать новое количество один раз и вывести сумму от него.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Общее правило: как только в обработчике появилось «новое значение», заведите под него переменную. Всё, что нужно от нового состояния, считайте от неё — count в этом обработчике уже не изменится никогда.