useOptimistic: показать результат заранее
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Сервер отвечает за полсекунды, и всю эту полсекунды экран честно показывает старое: запись не появилась, галочка не поставлена, лайк не закрашен. useOptimistic рисует ожидаемый результат сразу — а когда действие заканчивается, отпускает нарисованное и показывает то, что вернулось на самом деле. Кода отката при этом не появляется: «отпускает» и есть откат.
Форма с действием из прошлой статьи ведёт себя честно: пока действие не вернулось, состояние не менялось — значит и на экране ничего нового. Прогноз перед запуском: сколько строк «рендер» напечатается и сколько пунктов будет в списке через 200 мс после клика?
Полсекунды пустоты — это много. В списке дел ещё терпимо, в чате уже нет: человек нажал «отправить», сообщение не появилось, и он нажимает ещё раз. Приём, которым это лечат, называется оптимистичным интерфейсом: мы показываем результат, которого пока нет, потому что в девяти случаях из десяти он именно такой.
Первое, что приходит в голову, — положить запись в состояние сразу, а когда придёт ответ, подменить её настоящей. Кода становится втрое больше: у нарисованной записи нет настоящего id (его выдаст сервер), поэтому нужен временный, нужен признак «ещё не сохранена», нужна подмена после ответа. Прогноз: через сколько миллисекунд после клика запись окажется в списке?
Черновик не появился вообще. На 150 мс список пуст, на 300 мс пуст, и только на 600 мс в нём оказывается сохранённая запись — ровно как в первом примере, где никакого оптимизма не было.
Причина не в ошибке кода. Действие формы React выполняет внутри перехода — того самого, из статьи про useTransition, — а переход существует именно затем, чтобы не показывать полуготовых состояний. Все обновления, случившиеся внутри действия, React придерживает и выкатывает одним куском, когда действие закончилось. Оба setItems доехали до экрана вместе, и первый из них потерял всякий смысл.
Вне действия — в обычном onClick — setState коммитится сразу, и нарисовать заранее руками можно. Но тогда ваши и временный id, и подмена, и уборка в каждой ветке отказа.
Значит, нужен способ сказать React другое: «это обновление — не часть перехода, а картинка на время перехода». Ровно для этого и сделан useOptimistic.
Хук устроен как редьюсер, но с важным отличием: он ничего не хранит. Ему передают настоящее состояние и правило, как поверх него дорисовать ожидаемое. Пока идёт действие, компонент видит результат этого правила; действие кончилось — правило перестаёт применяться, и на экране снова ровно то, что лежит в настоящем состоянии.
| Что передаём | Что это |
|---|---|
passthrough | настоящее состояние — то, что видно, когда никаких действий не идёт |
reducer | (текущее, действие) → новое: как дорисовать ожидаемое |
| Что вернулось | Что это |
|---|---|
optimistic | состояние с наложением — его и рисуют в разметке |
addOptimistic | добавить наложение; принимает то, что редьюсер ждёт вторым аргументом |
Тот же список, но с наложением. Прогноз: сколько будет рендеров и что напечатается в том из них, который случится, пока действие в пути?
В середине вывода стоит строка, ради которой всё и затевалось: настоящих: 1 | на экране: 2. Два значения разошлись в одном и том же рендере — и это не рассинхронизация, а вся суть хука. items остаётся правдой, shown — это правда плюс то, чего мы ждём.
Обратите внимание, чего в коде нет. Нет вызова, снимающего наложение. Нет подмены черновика настоящей записью. Нет счётчика seq, а поле pending ставит редьюсер — в настоящих данных его нет и серверу оно не уедет. Осталось ровно два действия: дорисовать и сохранить.
Наложение живёт не в компоненте, а в переходе. Поэтому его нельзя «забыть снять»: React снимает его сам, когда действие завершилось — успехом, отказом или исключением.
Редьюсер задаёт и тип того, что принимает addOptimistic. Ошибиться легко: рука сама пишет туда готовый элемент списка вместо текста. Запустите и прочитайте, чего компилятор ждал.
У хука есть и однорукий вид: useOptimistic(value) без второго аргумента. Тогда возвращённая функция принимает не «действие», а сразу новое значение целиком — удобно, когда оптимистично меняется одна величина, а не список.
Самое неожиданное в useOptimistic — то, что при отказе не нужно делать ничего. Наложение действует, пока идёт действие, и перестаёт действовать, когда оно кончилось. Отказ — это тоже «кончилось». Настоящее состояние при отказе не менялось, значит компонент нарисует его как было, и нарисованное исчезнет само.
Прогноз: запись короткая, сервер её не примет. Появится ли она на экране вообще и что останется после ответа?
Запись появилась, повисела 400 мс и пропала, а под формой встала причина. Всё, что для этого написано, — addOptimistic(text) и setError(...). В ручном варианте из первого раздела ту же пропажу пришлось бы устраивать самим: завести ветку отказа и вынуть из списка filter-ом именно свой черновик, а не последний элемент.
Отсюда следствие про ошибки. В прошлой статье мы договорились: провалившаяся проверка — не поломка, её возвращают, а не бросают. Наложению всё равно: и возвращённый отказ, и брошенное исключение одинаково завершают действие, а значит одинаково его снимают. Разница в другом — после исключения компонент заменит собой граница ошибок, и показывать причину будет уже негде.
Второе следствие — про очередь. Наложение не одно: каждый вызов addOptimistic добавляет своё, и React прогоняет редьюсер по всем незавершённым действиям поверх настоящего состояния. Прогноз: два клика подряд, второй через 150 мс после первого. Сколько пунктов будет на экране через 300 мс и в каком порядке пойдут рендеры?
Оба наложения применились сразу — обе записи на экране, хотя настоящий список ещё пуст. А вот отпустились они не по очереди, а вместе: рендера с одной сохранённой записью и одним черновиком в выводе нет. Пока в очереди есть хоть одно незавершённое действие, наложения держатся все — и настоящее состояние приезжает сразу на две записи.
Ключи у нарисованных записей должны быть разными: два наложения — два элемента списка. Редьюсер получает уже накопленное состояние, поэтому длину для ключа удобно брать из него, а не из настоящего массива.
Во всех примерах выше addOptimistic вызывался внутри действия формы. Это не стилистика: наложение привязано к переходу и живёт ровно столько, сколько тот не завершился. Нет перехода — нечему длиться.
Прогноз: обычный обработчик onClick, внутри — addOptimistic и ожидание ответа. Что покажет кнопка через 200 мс после клика?
Первые три рендера идут подряд: 0, 1 и снова 0. Наложение применилось на один-единственный рендер и тут же было снято — держать его оказалось нечем. На экране за эти 400 мс не изменилось ничего.
Строка про переход в выводе — предупреждение самого React: «An optimistic state update occurred outside a transition or action». Его видно потому, что React для песочницы собран как для разработки: в боевой сборке остались бы только рендеры, то есть одно поведение без объяснения.
Лечится это переходом: startTransition с асинхронной функцией внутри и есть «действие», к которому наложение привяжется. Обновление настоящего состояния после await заворачивают в переход отдельно — граница ожидания разрывает связь с внешним переходом.
Итого — три места, откуда берётся действие, и все три годятся:
| Откуда действие | Как это выглядит |
|---|---|
| Форма | <form action={fn}> — React заворачивает fn в переход сам |
useActionState | обёртка formAction из прошлой статьи — тот же переход |
startTransition | ручной переход вокруг асинхронной функции — для кнопок вне форм |
Без этой обёртки код тоже работает, но появляется лишний кадр. Настоящее состояние приезжает срочным обновлением — сразу, — а наложение снимается вместе с концом перехода, чуть позже. Между ними есть рендер, где применено и то и другое: запись сохранена и нарисована. Уберите React.startTransition из строки с setItems и посмотрите на список через 700 мс.
На 700 мс пунктов три, хотя добавляли всего две записи: «полить цветы» уже сохранилась, а её наложение ещё держится — вторая отправка не закончилась и не даёт снять очередь. В самом конце в выводе мелькает и рендер с четырьмя пунктами. С startTransition из основного примера таких кадров нет: настоящее значение приезжает тем же переходом, который снимает наложение.
В форме, где уже есть useActionState, настоящим состоянием для наложения служит то, что вернуло действие. Ручной startTransition здесь не нужен вовсе: и обновление, и снятие наложения принадлежат одному действию.
Оптимистичный интерфейс — это ставка: мы утверждаем то, чего ещё не знаем. Ставка выгодна, когда отказ дёшев и легко объясним, и вредна, когда человек примет нарисованное за факт и уйдёт со страницы.
| Рисовать заранее | Не рисовать |
|---|---|
| Лайк, звёздочка, подписка — отказ ничего не портит | Оплата и списание денег: «прошло» говорит банк, а не браузер |
| Отправленное сообщение с пометкой «…» | Номер заказа, чек, срок доставки — их придумывает сервер |
| Галочка «сделано» в списке дел | Удаление, которое нельзя отменить |
| Переименование, сортировка, перенос между колонками | Всё, где отказ означает «вы неправильно поняли, что произошло» |
Отдельная ловушка — идентичность. У нарисованной записи нет настоящего id, и ключ ей приходится выдумывать. Когда придёт ответ, ключ сменится, React посчитает элемент новым и пересоздаст узел: пропадёт фокус, оборвётся анимация, слетит выделение текста. В списке из статичных строк это незаметно, в редактируемой — очень даже.
Лечится тем, что ключ перестаёт быть выдуманным: его генерирует клиент и отправляет серверу вместе с данными. Тогда «черновик» и сохранённая запись — это один и тот же узел с одним и тем же ключом.
Сервер при этом обязан считать такой id идемпотентным ключом: повторная отправка с тем же id не должна создавать вторую запись.
И последнее. Ничто в этой статье не принадлежит Next: useOptimistic — хук React 19, все примеры выше запустились в браузере без сервера и маршрутов. Меняется только то, откуда берётся «настоящее состояние». В Next после серверного действия страница перечитывает данные сама, и это перечитывание входит в то же действие — наложение отпускается ровно тогда, когда свежие данные уже приехали. Как устроено само перечитывание — тема следующей статьи.
- Внутри действия обычный setState на экран заранее не попадёт: переход придерживает все обновления до конца действия.
- useOptimistic ничего не хранит — он накладывает ожидаемое поверх настоящего состояния на время действия.
- Наложение снимается само, когда действие завершилось, поэтому кода отката в оптимистичном интерфейсе нет.
- Работает только внутри действия или перехода: из обычного onClick значение применится на один рендер и пропадёт.
- Наложения накапливаются очередью и отпускаются все разом, когда закончится последнее незавершённое действие.
- Рисовать заранее стоит дешёвое и обратимое: лайк, галочку, сообщение. Деньги, номера заказов и необратимое удаление — нет.
1/4Что передают первым аргументом в useOptimistic?
2/4Внутри действия формы вызвали обычный setState, а потом await на 400 мс. Когда обновление попадёт на экран?
3/4Действие вернуло отказ. Что нужно написать, чтобы нарисованная заранее запись исчезла с экрана?
4/4addOptimistic вызван в обычном обработчике onClick, без перехода вокруг. Что произойдёт?
Галочка, которая не ждёт
Переключатель «сделано» ждёт ответа сервера 400 мс и только потом закрашивается. Сделайте так, чтобы он менялся сразу, а при отказе (сервер не разрешает снимать галочку) возвращался назад сам.
Настоящее состояние остаётся в useState, а на экран идёт наложение. Редьюсер здесь не нужен: меняется одна величина целиком.
Обратите внимание на !shown, а не !done: следующий клик должен отталкиваться от того, что человек видит, а не от того, что пока лежит в настоящем состоянии.
Убрать бухгалтерию
Здесь оптимизм написан руками — и, как мы видели в первом разделе, изнутри действия он всё равно не работает: черновик появляется только вместе с ответом. Перепишите компонент на useOptimistic. Из типа Item должно уйти поле saved, а из действия — счётчик, подмена и уборка.
Настоящий список хранит только сохранённые записи, а признак «в пути» ставит редьюсер — в типе настоящих данных его больше нет.
Счётчик seq, filter и map с подменой исчезли целиком, а ветка отказа свелась к return.
Почему лайк не держится
Компонент выглядит правильно, но счётчик после клика не меняется: в выводе видно, что наложение применилось и тут же было снято. Почините, не заводя дополнительных состояний.
Наложение живёт внутри перехода, а обычный onClick перехода не создаёт. Достаточно завернуть тело обработчика в startTransition, не забыв про отдельный переход для обновления после await.
Второй вариант — перевести кнопку на форму с action: действие формы React заворачивает в переход сам, и ручной startTransition снаружи становится не нужен.