Оптимистичный UI с useOptimistic
useOptimistic показывает результат сразу, пока action в ожидании, и сам откатывается при сбое — безопасная форма: снимок, оптимистичное обновление, затем сверка или откат с видимой ошибкой, и никогда — для операций, которые пользователь не должен ложно считать выполненными.
Пользователь жмёт на сердечко под постом. Лайку нужно долететь до сервера, попасть в базу и вернуться — 300 мс на хорошем соединении, две секунды в метро. Если сердечко заполняется только после кругового рейса, приложение ощущается сломанным, даже когда всё в порядке. Поэтому ты заполняешь его мгновенно и даёшь запросу догнать. Этот инстинкт верен, и useOptimistic — хук, который позволяет сделать это без ручной логики отката.
Но у «покажи мгновенно» есть острый край. Запрос может упасть. Если сердечко остаётся заполненным после 500, твой UI теперь врёт о факте — и самая опасная версия этого: быть оптимистичным насчёт того, что пользователь никогда не должен ошибочно считать выполненным, например платежа. Этот урок — про безопасную форму паттерна и про черту, которую не пересекают.
После этого урока ты можешь использовать useOptimistic, чтобы показать ожидающую мутацию сразу и дать React откатить её автоматически, когда action завершится; структурировать любое оптимистичное обновление как снимок → оптимистичное обновление → сверка или откат с видимой обратной связью об ошибке; объяснить, почему состояние useOptimistic существует только на время жизни action и что это тебе даёт; и назвать операции, насчёт которых нельзя быть оптимистичным, — всё, где ложное «готово» приносит реальный вред (платежи, необратимые действия).
useOptimistic накладывает временное оптимистичное значение поверх твоего реального состояния ровно настолько, насколько action в ожидании, — затем оно исчезает, и проступает реальное состояние. Ты передаёшь ему текущее подтверждённое состояние и редьюсер, который производит оптимистичную версию. Пока transition/action выполняется, чтения возвращают оптимистичное значение; в момент, когда action оседает, React отбрасывает оптимистичный слой, и ты видишь то, чем теперь является реальное состояние.
"use client";
import { useOptimistic, useState, startTransition } from "react";
function LikeButton({ post }: { post: { id: string; likes: number; likedByMe: boolean } }) {
const [liked, setLiked] = useState(post.likedByMe);
const [count, setCount] = useState(post.likes);
// optimistic state is derived from the real state + a pending delta
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(state, nextLiked: boolean) => ({
liked: nextLiked,
count: state.count + (nextLiked ? 1 : -1),
}),
);
// ...
}Ключевая ментальная модель: optimistic — это не второй источник истины, который ты обязан держать в синхроне. Это вид, который React рисует поверх реального значения, пока работа в полёте, и убирает сам, когда работа сделана.
Безопасная форма — всегда три хода: сделай снимок истины, примени оптимистичное обновление, затем сверь или откати — с видимым, а не молчаливым, путём ошибки. Снимок здесь неявный: твои реальные useState/серверные данные — это снимок, и useOptimistic никогда их не мутирует. Ты применяешь оптимистичное обновление внутри transition, делаешь реальную мутацию, и при успехе фиксируешь её в реальном состоянии; при сбое ты ничего не делаешь с реальным состоянием (так что оптимистичный слой просто исчезает, и истина возвращается) и показываешь ошибку.
const [error, setError] = useState<string | null>(null);
function toggle() {
const next = !liked;
setError(null);
startTransition(async () => {
setOptimistic(next); // 2. apply optimistic — heart fills NOW
try {
const res = await likePost(post.id, next); // 3a. real mutation
setLiked(next); // commit truth on success
setCount(res.likes); // reconcile with the server's number
} catch {
setError("Couldn't update like — tap to retry"); // 3b. visible revert
// real state untouched → optimistic layer drops → heart un-fills
}
});
}Сверка означает пересинхронизироваться с тем, что сервер реально сказал (res.likes может отличаться от твоей догадки, если другой пользователь тоже лайкнул). Откат означает: не трогай реальное состояние, дай React снять оптимистичный слой и скажи пользователю.
Режим отказа, который определяет этот паттерн: оптимистичное обновление без отката и без пути ошибки. Тогда UI врёт всякий раз, когда запрос падает. Такое легко выкатить, потому что счастливый путь выглядит идеально в dev, где запросы никогда не падают. Запах — это setOptimistic(...), за которым следует «выстрелил и забыл» мутация без catch, или catch {}, который проглатывает ошибку. Когда сеть отваливается, сердечко остаётся заполненным, элемент остаётся в списке, счётчик неверный — а пользователь понятия не имеет, что его действие не прошло.
// BROKEN: optimistic with no error path — the UI lies on failure
function toggle() {
startTransition(async () => {
setOptimistic(!liked);
setLiked(!liked); // commits truth BEFORE the server confirms
await likePost(post.id, !liked).catch(() => {}); // swallowed → silent lie
});
}Исправление структурное, а не косметическое: никогда не фиксируй реальное состояние до подтверждения сервера и всегда имей catch, который даёт пользователю обратную связь. Оптимизм без пути отката — это не оптимизм, это баг, который сам себя прячет.
Не будь оптимистичным насчёт операций, которые пользователь не должен ошибочно считать выполненными, — платежи, удаления-с-последствиями, всё необратимое. Оптимистичный UI меняет корректность-в-моменте на скорость, ставя на то, что действие удастся. Эта ставка нормальна для лайка или добавления задачи: если оно упадёт, пользователь пожмёт плечами и повторит. Она не нормальна для «Оплатить $400» или «Удалить аккаунт», где ложное «Готово ✓» уводит пользователя в уверенности, что произошло то, чего не произошло. Для них показывай реальное состояние ожидания (спиннер, заблокированную кнопку) и подтверждай только после подтверждения сервера.
// like / add-to-list → optimistic is great (cheap to be wrong, instant feel)
// "Pay now" / "Delete forever" → NEVER optimistic; the user acts on a false truth
// use a pending UI instead:
const [isPending, startTransition] = useTransition();
<button disabled={isPending}>{isPending ? "Charging…" : "Pay $400"}</button>Senior-суждение — по-действию: спроси «если это оптимистичное обновление окажется неверным, что пользователь сделает с этим ложным убеждением?». Если ответ «ничего плохого» — будь оптимистичным. Если «он думает, что заплатил» — нет.
Добавление в список, до и после — разница целиком в пути отката. Список «сохранить на потом» добавляет элемент через серверный action. Ниже — наивная версия, которую все пишут первой.
// BEFORE: optimistic append, no rollback — lies on failure
function SaveList({ items }: { items: Item[] }) {
const [list, setList] = useState(items);
const [optimistic, addOptimistic] = useOptimistic(
list,
(state, item: Item) => [...state, item],
);
function add(item: Item) {
startTransition(async () => {
addOptimistic(item);
setList((l) => [...l, item]); // commits before the server confirms
await saveItem(item); // if this throws, item is wrongly kept
});
}
return <Rows items={optimistic} onAdd={add} />;
}Если saveItem отклоняется, элемент уже в list, поэтому он остаётся навсегда — UI заявляет о сохранении, которого не было, без показанной ошибки. Теперь безопасная версия: реальное состояние фиксируется только при успехе, а сбой показывается.
// AFTER: snapshot (list) untouched until success; visible revert on failure
function SaveList({ items }: { items: Item[] }) {
const [list, setList] = useState(items);
const [error, setError] = useState<string | null>(null);
const [optimistic, addOptimistic] = useOptimistic(
list,
(state, item: Item) => [...state, item],
);
function add(item: Item) {
setError(null);
startTransition(async () => {
addOptimistic(item); // appears instantly
try {
const saved = await saveItem(item); // reconcile with server's record
setList((l) => [...l, saved]); // commit truth (server may add an id)
} catch {
setError("Couldn't save — item removed, try again");
// list never changed → optimistic layer drops → row disappears
}
});
}
return (
<>
{error && <p role="alert" className="error">{error}</p>}
<Rows items={optimistic} onAdd={add} />
</>
);
}Тот же мгновенный отклик, но теперь упавшее сохранение само себя отзывает и сообщает пользователю. Оптимистичная строка всегда была лишь слоем поверх list; поскольку мы ни разу не зафиксировали list при сбое, React убирает её автоматически, когда action завершается. Этот самоисцеляющийся откат — вся причина использовать useOptimistic вместо ручного добавления в реальное состояние и вырезания из него.
▸Почему это работает
Почему useOptimistic не нужен явный вызов «отменить»? Потому что оптимистичное значение выводится, а не хранится. Оно существует только пока transition в ожидании; React пересчитывает его из твоего реального состояния через редьюсер, и когда action оседает, он вовсе перестаёт применять редьюсер — так что чтения откатываются к реальному состоянию. Если бы ты вместо этого добавил оптимистичный элемент в реальный массив useState, откат принадлежал бы тебе, и тебе пришлось бы помнить вырезать его на каждом пути ошибки. Вывод оптимистичного слоя делает откат автоматическим и невозможным забыть — что и есть ровно та причина, по которой безопасный паттерн звучит как «оставь реальное состояние в покое при сбое».
▸Частая ошибка
Тонкая ошибка — сверять неверно: при успехе фиксировать своё угаданное оптимистичное значение вместо реального ответа сервера. Твой оптимистичный счётчик был count + 1, но между твоим чтением и записью другой пользователь тоже лайкнул пост — сервер говорит count + 2. Если ты зафиксируешь свою догадку, реальное состояние теперь молча неверно и исправится только при перезапросе. Всегда сверяйся с ответом сервера (res.likes, saved.id), а не с оптимистичной догадкой. Оптимистичный UI — это временная ложь, которую ты говоришь пользователю; сверка — это место, где ты делаешь её правдой.
Кнопка «Оплатить $400» использует useOptimistic, чтобы мгновенно показать «Платёж завершён ✓» в момент нажатия, затем запускает списание в фоне с catch, который пишет в консоль. В чём суть проблемы?
useOptimistic рисует временное, выведенное значение поверх твоего реального состояния ровно настолько, насколько action в ожидании, затем снимает его автоматически — это и делает мутации мгновенными без ручной отмены. Безопасная форма всегда одна и та же — три хода: снимок (твоё реальное состояние, которое ты никогда не мутируешь оптимистично), наложи оптимистичное обновление внутри transition, затем сверь с реальным ответом сервера при успехе или откати при сбое — и откат должен быть видимым, показанным пользователю, а не проглоченным catch. Определяющий режим отказа паттерна — оптимистичное обновление без пути ошибки: UI продолжает показывать успех, которого не было, и врёт молча. И жёсткая черта — это суждение по-действию: никогда не будь оптимистичным насчёт операций, которые пользователь не должен ошибочно считать выполненными, — платежи и необратимые действия получают реальное состояние ожидания, потому что ложное «готово» там не косметический сбой, а пользователь, действующий на основе того, чего не произошло.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.