Функция-обновитель и очередь обновлений

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

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

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

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

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

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

Единица вместо двойки — и всего две строки «отрисовка»: одна при первой отрисовке, одна после нажатия. Обе строки обработчика заказали одно и то же: «поставить в состояние 1». count внутри обработчика — константа этой отрисовки, поэтому count + 1 оба раза посчиталось как 0 + 1, а второй заказ просто перекрыл первый.

Для React нет разницы между setCount(count + 1) и setCount(1), пока count равен нулю: к моменту вызова выражение уже посчитано, в очередь ложится готовое число.

Вторая половина вывода не менее важна. React не перерисовывает компонент после каждого set: он дожидается, пока обработчик доработает до конца, и только потом берётся за отрисовку. Иначе обработчик с двумя обновлениями показал бы промежуточный кадр — состояние, которого в вашей голове никогда не было.

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

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

Двойка — и по-прежнему одна отрисовка на нажатие. Параметр n — это не count из отрисовки: первый обновитель получил 0 и вернул 1, второй получил эту единицу и вернул 2. Такую функцию в React называют функцией-обновителем (updater function).

Имя параметра любое: n, prev, c. Привычка сообщества — короткое имя по первой букве состояния: setTasks(t => ...), setUser(u => ...).

Что написаноЧто попадает в очередьИтог при count === 0
setCount(1)число 11
setCount(count + 1)число 1 — выражение посчиталось сразу1
setCount(count + 1) дваждычисла 1 и 11
setCount(n => n + 1) дваждыдве функции2

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

Здесь очередь смешанная. Посчитайте её на бумаге, прежде чем нажимать Run.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
Запись в очередиЧто делаетНакоплено
1заменяет1
n => n + 10преобразует11
100заменяет100
n => n + 1преобразует101

Число в очереди стирает всё, что накопилось раньше: setCount(100) перечеркнуло работу первых двух записей. Это стоит помнить, когда set вызывается в разных ветках одного обработчика — выигрывает не «главная» ветка, а последняя сработавшая.

Очередь у каждого состояния своя. setCount и setStep не соперничают: React разбирает их независимо друг от друга.

В части II вы писали перерисовку руками: меняли состояние и звали render(). Два изменения подряд означали два полных перестроения разметки, и удержаться от лишнего вызова было нечем — приходилось следить самому. React делает это за вас: собирает все обновления одного события в пачку и перерисовывает один раз. Приём называют объединением обновлений (batching).

До React 18 объединялись только обновления внутри обработчиков самого React. Если set вызывался из таймера, из .then промиса или из подписки, каждый давал свою отрисовку. С восемнадцатой версии объединяется всё — вот тот самый случай.

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

Два состояния, два set внутри setTimeout — и одна новая строка «отрисовка». Порядок строк тоже говорящий: сообщение из таймера напечаталось раньше, чем компонент отрисовался заново. Обновление состояния не останавливает выполнение кода, оно только оставляет заявку.

Редкий случай: сразу после обновления нужно прочитать разметку — измерить высоту списка, прокрутить к новому элементу. Тогда пригодится flushSync из react-dom: он заставляет React отрисовать и применить изменения немедленно, не дожидаясь конца обработчика. Это инструмент на крайний случай, платой становится лишняя синхронная отрисовка. Что именно происходит между заявкой и изменением страницы, разбирает статья про триггер, рендер и коммит.

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

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

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

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

Одна замена — и получилось три. Обновителю снимок не нужен: он получает значение в момент разбора очереди, а не в момент, когда его написали.

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

Обратное тоже верно: setCount(0), setUser(null), setText(input.value) — новое значение не зависит от старого, обновитель тут ни к чему.

У обновителя ровно одна работа: получить старое значение и вернуть новое. Ничего другого в нём делать нельзя — ни менять внешние переменные, ни отправлять запросы, ни звать другие set. Причина не в чистоплюйстве: момент вызова выбирает React, и он не обязан совпадать с тем, где вы написали set. В отладочной сборке внутри <StrictMode> React вдобавок вызывает обновитель дважды — специально, чтобы нечистая функция выдала себя; в песочнице учебника StrictMode не включён, и здесь обновитель зовётся по разу.

Самая частая ошибка в обновителе — фигурные скобки без return. Компилятор ловит её сразу.

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

Диагностика читается сверху вниз: «Аргумент типа (n: number) => void нельзя назначить параметру типа SetStateAction<number>», а ниже причина — «Тип void не может быть назначен для типа number». SetStateAction<number> — это объединение number | ((prev: number) => number): либо готовое число, либо функция, возвращающая число. Стрелка с фигурными скобками не возвращает ничего, поэтому во второй вариант объединения она не подошла.

Пример всё равно выполняется — типы стираются, и до браузера доезжает обычный JavaScript. Нажмите кнопку в кадре: в состояние попадёт undefined, и число на кнопке просто исчезнет. Молчаливая порча состояния — как раз то, ради чего стоит дочитывать диагностики до конца.

Обычно тип сеттера выводится сам и вслух не произносится. Назвать его приходится, когда сеттер передают вниз пропсом: React.Dispatch<React.SetStateAction<number>> — «функция, принимающая либо число, либо обновитель».

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

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

  • Аргумент-значение фиксирует новое состояние в момент вызова; аргумент-функция откладывает вычисление до разбора очереди.
  • Каждое обращение к set добавляет запись в очередь состояния: число заменяет накопленное, обновитель преобразует его.
  • Пока обработчик не доработал, React ничего не перерисовывает: все обновления события сливаются в одну отрисовку, и с версии 18 это верно и для таймеров с промисами.
  • Обновитель обязателен там, где новое значение считается из старого, — особенно в отложенном коде, где снимок к моменту применения устареет.
  • Обновитель — чистая функция: только вычисляет и обязательно возвращает значение; забытый return компилятор ловит сообщением про void.

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

2/4Состояние count равно 0. Обработчик вызывает setCount(5), а следующей строкой — setCount(n => n * 2). Что окажется в состоянии?

3/4Внутри одного setTimeout вызваны setCount и setStep. Сколько раз React 19 отрисует компонент?

4/4Что скажет компилятор на setCount((n) => { n + 1; }), если состояние типа number?

Счётчик, который не считает

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

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

n++ — постфиксный инкремент: он увеличивает переменную, но возвращает старое значение. Обновитель вернул 0, React сравнил новое значение со старым, не нашёл разницы и не стал ни менять состояние, ни перерисовывать компонент. Менять n внутри обновителя вдобавок бессмысленно: это локальный параметр, его изменения никто не увидит.

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

Формально сработал бы и ++n — префиксный инкремент возвращает уже увеличенное значение. Но обновитель должен вычислять, а не изменять, поэтому в нём пишут n + 1.

Кнопка «плюс три»

Одно нажатие должно прибавлять три. Три вызова set в обработчике уже есть — считайте, что каждый пришёл из своего места программы и склеить их в одно выражение нельзя. Сейчас счётчик прибавляет единицу.

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

Каждый вызов должен опираться не на снимок, а на накопленное в очереди значение. Значит, все три — обновители.

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

Отрисовка при этом остаётся одна: три записи очереди разбираются целиком, и только потом компонент вызывается заново. Проверить это можно строкой console.log("отрисовка, count =", count) в теле компонента — после нажатия появится ровно одна новая строка, сразу с тройкой.

Очередь своими руками

Напишите apply: она получает очередь обновлений — вперемешку числа и функции — и начальное значение, а возвращает то, что окажется в состоянии после разбора очереди. Ровно эту работу React делает перед каждой отрисовкой.

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

Очередь сворачивается в одно значение — это reduce из части I. Тип элемента number | Updater разбирается проверкой typeof: в ветке с функцией сужение из части III даёт уже Updater, и её можно вызвать.

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

Явный параметр типа у reduce<number> нужен потому, что иначе тип накопителя выведется из массива — то есть станет number | Updater, и вернуть такое из функции с обещанным number не получится.