open atlas
↑ К треку
Паттерны React RXP · 12 · 03

Оптимистичный UI с useOptimistic

useOptimistic показывает результат сразу, пока action в ожидании, и сам откатывается при сбое — безопасная форма: снимок, оптимистичное обновление, затем сверка или откат с видимой ошибкой, и никогда — для операций, которые пользователь не должен ложно считать выполненными.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Пользователь жмёт на сердечко под постом. Лайку нужно долететь до сервера, попасть в базу и вернуться — 300 мс на хорошем соединении, две секунды в метро. Если сердечко заполняется только после кругового рейса, приложение ощущается сломанным, даже когда всё в порядке. Поэтому ты заполняешь его мгновенно и даёшь запросу догнать. Этот инстинкт верен, и useOptimistic — хук, который позволяет сделать это без ручной логики отката.

Но у «покажи мгновенно» есть острый край. Запрос может упасть. Если сердечко остаётся заполненным после 500, твой UI теперь врёт о факте — и самая опасная версия этого: быть оптимистичным насчёт того, что пользователь никогда не должен ошибочно считать выполненным, например платежа. Этот урок — про безопасную форму паттерна и про черту, которую не пересекают.

Цель

После этого урока ты можешь использовать useOptimistic, чтобы показать ожидающую мутацию сразу и дать React откатить её автоматически, когда action завершится; структурировать любое оптимистичное обновление как снимок → оптимистичное обновление → сверка или откат с видимой обратной связью об ошибке; объяснить, почему состояние useOptimistic существует только на время жизни action и что это тебе даёт; и назвать операции, насчёт которых нельзя быть оптимистичным, — всё, где ложное «готово» приносит реальный вред (платежи, необратимые действия).

1

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 рисует поверх реального значения, пока работа в полёте, и убирает сам, когда работа сделана.

2

Безопасная форма — всегда три хода: сделай снимок истины, примени оптимистичное обновление, затем сверь или откати — с видимым, а не молчаливым, путём ошибки. Снимок здесь неявный: твои реальные 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 снять оптимистичный слой и скажи пользователю.

3

Режим отказа, который определяет этот паттерн: оптимистичное обновление без отката и без пути ошибки. Тогда 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, который даёт пользователю обратную связь. Оптимизм без пути отката — это не оптимизм, это баг, который сам себя прячет.

4

Не будь оптимистичным насчёт операций, которые пользователь не должен ошибочно считать выполненными, — платежи, удаления-с-последствиями, всё необратимое. Оптимистичный 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.