open atlas
↑ К треку
React с нуля до senior RCT · 04 · 04

Оптимистичный UI и useOptimistic: честно врать пользователю

useOptimistic рендерит желаемое состояние, пока летит мутация, и возвращается к каноничному, когда transition завершился — откат бесплатен. Сверка с истиной сервера, ключи идемпотентности против двойного сабмита и знание, где оптимизм неуместен (платежи, удаления), — на вас.

RCT Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Два бага от одной команды ленты с разницей в шесть недель. Первый — двойной лайк: пользователь на гостиничном wifi жмёт сердечко, две секунды не видит ничего (команда показывала лайк только после подтверждения сервера), решает, что промахнулся, и жмёт снова. Оба запроса в итоге доезжают; счётчик показывает 2 от одного человеческого жеста, а тумблер лайка инвертирован — ещё одно нажатие «снимает лайк» до 1 вместо 0. Второй — исчезающий комментарий: когда команда вручную перешла на оптимизм, пользователь постит комментарий, видит его мгновенно — и через три секунды смотрит, как тот испаряется. Самодельный откат был catch-блоком, выsplice-ивающим комментарий из локального состояния, но с ним сгонялся refetch, запущенный другим компонентом: снапшот refetch-а (снятый до вставки) перезаписал список, а splice затем отработал по чужому массиву. Комментарий на самом деле сохранился; UI просто потерял нить. Оптимистичный UI — правильный выбор для кнопки лайка: воспринимаемая задержка падает с round-trip до нуля. Но самодельный оптимизм — это домашка по распределённым системам: вы ведёте две линии времени, локальную надежду и серверную истину, и каждое слияние между ними — шанс соврать. React 19 поставляет дисциплину слияния как хук.

Механизм: оверлей с истекающим сроком

useOptimistic(canonicalState, mergeFn) возвращает состояние для рендера плюс addOptimistic. Семантика — то, что нужно усвоить: хук не форкает ваше состояние — он рендерит оверлей. Пока никакая мутация не висит, он возвращает каноничное состояние нетронутым. Когда вы вызываете addOptimistic(input) внутри transition (form action — это transition), React рендерит mergeFn(canonicalState, input) — каноничное состояние плюс вашу надежду — ровно столько, сколько transition в полёте. Когда transition завершается, оверлей истекает, и хук возвращает то, чем каноничное состояние стало теперь. Отменять нечего, потому что ничего не было записано: откат при провале — не код, который вы пишете, а истечение оверлея при каноничном состоянии, которое не успело измениться.

function Comments({ comments, postComment }) {
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, newComment) => [
      ...current,
      { ...newComment, pending: true },
    ]
  );

  async function action(formData) {
    const text = formData.get("text");
    addOptimistic({ id: crypto.randomUUID(), text });
    await postComment(text); // on success, parent refetches → comments updates
  }

  return (
    <form action={action}>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id} style={{ opacity: c.pending ? 0.5 : 1 }}>
            {c.text}
          </li>
        ))}
      </ul>
      <input name="text" />
    </form>
  );
}

Путь успеха: action ждёт POST, родитель обновляет comments (refetch или слияние ответа), transition завершается, и оверлей сменяется каноничным состоянием, которое теперь содержит комментарий — пользователь не видит шва. Путь провала: action бросает или возвращает ошибку, каноничное состояние не менялось, оверлей истекает — комментарий гаснет сам. Флаг pending: true — маркер честности: приглушение или лёгкий спиннер говорят пользователю, что этот элемент — обещание, а не факт, и редкое исчезновение становится объяснимым, а не мистическим. Баг исчезающего комментария из Hook в этой модели невыразим — нечему гнаться со splice-ом, потому что второй записываемой копии списка не существует.

Сверка: истина сервера побеждает — даже когда не согласна

Оверлей отвечает на «что показывать, пока ждём» — сверка отвечает на «что делать, когда пришла истина». Правило абсолютно: оптимистичное значение — это предсказание, и ответ сервера замещает его, даже если они расходятся. Сервер может нормализовать текст комментария, навесить флаги модерации, выдать настоящие id и timestamp; каноничное состояние строится из ответа (или refetch-а) — никогда повышением оптимистичного объекта до «настоящего». Команды, повышающие предсказание, получают дрейф: UI показывает домодерационный текст до следующей полной перезагрузки, а временный клиентский id протекает в якоря и аналитику.

Идентичность — острая кромка. У оптимистичного элемента клиентский ключ; у серверного — настоящий id. Если слияние идёт по позиции в массиве или по небрежному равенству, медленный успех ненадолго покажет оба — приглушённое предсказание и подтверждённый элемент. Несите на оптимистичном элементе клиентский ключ, пусть сервер вернёт его эхом (большинство API возвращают присланное) и сшивайте по нему.

Идемпотентность: пережить второе нажатие

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

Лекарство — ключ идемпотентности: клиент генерирует уникальный ключ на намерение (не на запрос) — crypto.randomUUID() в момент жеста — и шлёт его с мутацией; ретрай шлёт тот же ключ. Сервер хранит обработанные ключи и на повтор возвращает записанный результат вместо повторного исполнения. Это превращает доставку «хотя бы раз» в эффект «ровно раз» — тот же контракт, который ровно по этой причине выставляют платёжные API. Заметьте разделение труда: React даёт оверлей и pending-флаг; идемпотентность — протокол между вашим клиентским кодом и вашим API, и никакой хук её не подвезёт.

Почему это работает

Почему useOptimistic требует transition или action? Потому что время жизни оверлея определяется именно им: показ длится от addOptimistic до завершения объемлющего transition. Вне всякого transition нет скобки, к которой привязать надежду, — React 19.x предупреждает, что оптимистичное обновление сделано вне transition, и значение почти сразу отщёлкивает назад. Эта пара и есть дизайн: actions дают мутациям отслеживаемое время жизни, а useOptimistic арендует ровно это время для своей лжи.

Когда оптимизм — неверная ставка

Оптимизм — пари, что успех подавляюще вероятен, а провал дёшево забрать обратно. Лайки, подписки, эмодзи-реакции, отметка прочитанным, перестановка задач в списке: успех в высоких 99-х, откат — лёгкое затухание; ставьте всегда. Пари переворачивается в трёх ситуациях. Деньги и всё юридически значимое: показать «Платёж выполнен» до ответа процессинга — не сокрытие задержки, а ложный чек: пользователь закрывает вкладку и уходит, веря в то, что может оказаться неправдой. Деструктивные, трудно обратимые операции: оптимистично исчезающие «Удалить аккаунт» или «Отменить подписку» подтверждают необратимый акт, который сервер ещё может отвергнуть, — а пользователь уже действует исходя из подтверждения. Мутации с низкой уверенностью: всё с заметной долей отказов (модерируемый контент в строгом сообществе, выбор мест под конкуренцией, резервирование остатков) — когда провал рутина, а не исключение, UI постоянно «забирает обратно» показанное, и каждое изъятие жжёт доверие, которое оптимизм должен был строить. Здесь выигрывает честный pending: отключить, показать прогресс, подтвердить по истине. Пользователь прощает 800 мс спиннера на платеже; он не прощает чек, который отозвали.

Викторина

С useOptimistic мутация провалилась (action бросил исключение), и оптимистичный комментарий исчез. Где код отката, который его убрал?

Викторина

Пользователь жмёт «лайк», запрос успешен, но ответ теряется при обрыве wifi; пользователь жмёт снова, и счётчик приходит к 2. Какой фикс бьёт в корневую причину?

Вспомните перед уходом
  1. 01
    Объясните оверлейную модель useOptimistic и почему она структурно исключает гонку исчезающего комментария, от которой страдает самодельный оптимизм.
  2. 02
    Чек-лист сеньорского ревью оптимистичной мутации: какие три гарантии должны стоять помимо вызова useOptimistic, и когда ревьюер должен отвергнуть оптимизм целиком?
Итог

Оптимистичный UI схлопывает воспринимаемую задержку мутации с сетевого круга до нуля, рендеря желаемое состояние сразу — а useOptimistic в React 19 делает опасную половину этой сделки структурной. Хук возвращает оверлей, а не форк: вне висящего transition вы видите каноничное состояние; после addOptimistic внутри transition — слияние каноничного состояния с вашей надеждой, пока transition не завершится и оверлей не истечёт. Успех заменяет надежду истиной сервера, потому что action обновил каноничное состояние; провал не требует кода отката вовсе, потому что ничего не было записано — именно поэтому самодельная гонка catch-и-splice (исчезающий комментарий, перезаписанный протухшим refetch-ем) в этой модели невыразима. Что остаётся на вас: сверка — ответ сервера всегда замещает предсказание: пересобирайте каноничное состояние из ответа, сшивайте элементы по клиентскому ключу-эху, не повышайте оптимистичный объект; идемпотентность — ключ на жест, хранимый сервером, превращает неизбежный двойной сабмит (потерянный ответ, второе нажатие, двойной лайк со счётчиком 2) в переигранный результат вместо второго исполнения; и суждение — оптимизм есть пари, что успех почти гарантирован, а изъятие дёшево. Берите пари для лайков, подписок, перестановок, отметок прочитанным. Отказывайтесь для платежей, деструктивных операций и любой мутации с рутинными отказами — UI, постоянно отбирающий показанное, тратит доверие, ради которого строился.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.