useOptimistic Rollback: Пять способов сбоя в серверных действиях Next.js
Узнайте, почему useOptimistic автоматически восстанавливает интерфейс без объяснения проблем пользователю, с помощью пяти проверенных режимов сбоев Server Action и рабочего решения.
Автоматическое отката работает ровно так, как обещалось. Однако отображение сообщения об ошибке перед пользователем — нет. Я намеренно вызвал пять разных сценариев сбоя в компоненте Server Action в Next.js и записал то, что действительно появилось на экране.
Откат не стоит ничего. Однако информирование о сбое — да. Статус переключается в платный, а затем молча возвращается к исходному состоянию, без каких-либо объяснений, которые мог бы прочитать пользователь.
Большинство учебных материалов рассматривают useOptimistic как бесплатную кнопку отмены. Вы нажимаете её, интерфейс меняется, запрос терпит неудачу, и интерфейс возвращается к начальному состоянию. Больше ничего.
Я хотел проверить, сработает ли это решение, когда ситуация станет сложной. Поэтому я создал небольшой список счетов-фактур в Next.js App Router — пять строк, каждая из которых связана со своей собственной Server Action, — и в реальном коде специально создал пять разных способов, как может сбойть эта операция. Это не выдуманные крайние случаи, а те ошибки, которые попадают в продакшн незамеченными.
При пяти запусках для каждого способа сбоя три из них правильно вернули интерфейс в исходное состояние. Два оставили интерфейс с неверной информацией. Автоматическое восстановление работает, когда операция выбрасывает исключение. Однако оно не срабатывает, когда операция тихо возвращает что-то вроде { ok: false } вместо того, чтобы выбросить исключение — такой подход является тонким способом введения пользователя в заблуждение без намерения.
Для справки: это было создано на основе версии 15.5.2 Next.js в сочетании с React 19.1.1, скомпилировано с использованием TypeScript 5.9.2 и работает под управлением версии Node 22.x на системе Linux. Приведенные ниже цифры получены именно в этой среде. Если вы запустите ту же программу на другом компьютере, временные показатели могут незначительно измениться, но структура каждой ошибки должна остаться прежней.
Что на самом деле обещают документация
На официальной странице справки по useOptimistic четко указано: оптимистичное значение отображается только до завершения выполнения действия; как только оно фиксируется, React переходит к отображению того значения value, которое в данный момент действительно существует.
В нём также объясняется, что происходит при возникновении ошибок. Короче говоря: незафиксированная ошибка внутри Action всё равно позволяет запланированной Transition завершиться в обычном режиме. Поскольку окружающий код обычно записывает данные в реальное значение value только после успешного вызова, ошибка означает, что это значение так и не было изменено — поэтому, как только Transition завершается, React просто отображает тот же интерфейс, который пользователь видел до нажатия кнопки. В документации указано, что если вы хотите показать пользователю какое-либо сообщение, вам необходимо самостоятельно зафиксировать эту ошибку; React сделает это за вас не будет.
Здесь важны ещё два момента:
- Оптимистичный сеттер должен выполняться внутри Action или внутри функции
startTransition. Если он выполняется вне них, React выводит предупреждение, и оптимистичный интерфейс появляется лишь на короткое время, прежде чем исчезнуть.
Второй момент на самом деле является сутью всей этой концепции. Возврат интерфейса к исходному состоянию происходит автоматически. Объяснение пользователю причин этого зависит от вас. Функция hook отображает предполагаемое значение до тех пор, пока действие находится в ожидании, а затем синхронизируется с реальным значением у родительского элемента. Если выполнить действие, не изменяя это базовое значение, произойдет откат. Если действие будет выполнено успешно без изменений, также произойдет откат. Если действие выполнено успешно, но базовое значение обновлено неверным результатом, появится «призрачное» состояние — интерфейс, отображающий то, чего на самом деле не происходило на сервере.
Мини-приложение: переключатель оплаты счета
Вместо простого примера тестовое приложение имитирует экран выставления счетов, поскольку именно там неверная метка «Оплачено» приводит к звонку от службы взыскания долгов.
Вот как оно структурировано:
- Страница RSC загружает пять счетов из внутренней памяти (
INV-1001доINV-1005); все они изначально неоплачены. - Каждая строка отображается как отдельный клиентский компонент, содержащий булевое значение для отслеживания статуса.
- При нажатии на кнопку включения/выключения вызывается действие сервера с явно указанным булевым значением
paid. - Команда
revalidatePath('/invoices')выполняется только в случае успешного выполнения действия. - Каждая строка содержит счетчик отрисовок, который увеличивается при каждой перерисовке. Режим строгой проверки отключен, чтобы этот счетчик не увеличивался из-за двойных вызовов.
await sleep(400), чтобы оптимистичный окно времени было достаточно длинным для визуального наблюдения и измерения с помощью performance.now().// app/invoices/page.tsx
import { getInvoices } from '@/lib/invoices';
import { InvoiceRow } from './invoice-row';
export default async function InvoicesPage() {
const invoices = await getInvoices();
return (
<ul>
{invoices.map((inv) => (
<InvoiceRow key={inv.id} invoice={inv} />
))}
</ul>
);
}
Правила для каждой запуска теста: сбрасывать хранилище счетов в состояние «не оплачено», устанавливать активный режим FAIL_MODE, нажимать на переключатель один раз (два раза для пятого режима), ждать 800 мс после завершения действия, затем проверять текст кнопки, проверять значение data-renders, отмечать любые предупреждения в консоли и делать скриншот. Каждый режим тестировался пять раз, всегда с одним и тем же идентификатором счета, без использования React Query и внешних слоев кэширования — только RSC-атрибуты, функция useOptimistic и одно серверное действие.
Компонент строки «успешного сценария» выглядит как что-то из любого вводного учебника:
// app/invoices/invoice-row.tsx — broken happy-tutorial version
'use client';
import { useOptimistic, startTransition, useRef } from 'react';
import { togglePaid } from './actions';
import type { Invoice } from '@/lib/invoices';
export function InvoiceRow({ invoice }: { invoice: Invoice }) {
const renders = useRef(0);
renders.current += 1;
const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
function onToggle() {
startTransition(async () => {
setOptimisticPaid(!optimisticPaid);
await togglePaid(invoice.id, !optimisticPaid);
// hope revalidatePath inside the action fixes the base prop
});
}
return (
<li data-renders={renders.current}>
<span>{invoice.number}</span>
<button type="button" onClick={onToggle} aria-pressed={optimisticPaid}>
{optimisticPaid ? 'Paid' : 'Unpaid'}
</button>
</li>
);
}
Вот само действие сервера, с переключателем сбоя, который тестовый фреймворк может активировать по требованию:
// app/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { setPaid } from '@/lib/invoices';
const ToggleSchema = z.object({
id: z.string().uuid(),
paid: z.boolean(),
});
export type ToggleResult =
| { ok: true }
| { ok: false; code: 'VALIDATION' | 'BIZ'; message: string };
let FAIL_MODE:
| 'none'
| 'throw'
| 'soft'
| 'zod'
| 'race' = 'none';
export function __setFailMode(mode: typeof FAIL_MODE) {
FAIL_MODE = mode;
}
export async function togglePaid(
id: string,
paid: boolean,
): Promise<ToggleResult> {
await new Promise((r) => setTimeout(r, 400)); // visible optimistic window
if (FAIL_MODE === 'throw') {
throw new Error('DB write failed');
}
const parsed = ToggleSchema.safeParse({ id, paid });
if (!parsed.success || FAIL_MODE === 'zod') {
return {
ok: false,
code: 'VALIDATION',
message: 'Invalid toggle payload',
};
}
if (FAIL_MODE === 'soft') {
return { ok: false, code: 'BIZ', message: 'Invoice locked' };
}
await setPaid(id, paid);
if (FAIL_MODE === 'race') {
// succeed, revalidate, then a second overlapping call fights it
revalidatePath('/invoices');
return { ok: true };
}
revalidatePath('/invoices');
return { ok: true };
}
Режим сбоя 1 — серверное действие выбрасывает исключение
FAIL_MODE = 'throw'. Действие выбрасывает исключение через 400 мс. На стороне клиента ничего не ловит это исключение. Это именно тот сценарий, который описан в документации.
Ожидаемое поведение: переход завершается, значение invoice.paid не меняется, оптимистичный слой исчезает, а кнопка снова показывает статус «Не оплачено».
Фактическое поведение (5 из 5 запусков):
- t=0 мс: происходит нажатие, метка сразу меняется на «Оплачено» (благодаря оптимистичному обновлению)
- t≈400 мс: появляется исключение, что прерывает переход
- t≈410 мс: метка снова меняется на «Не оплачено»
Поведение при откате: соответствует описанию. Обработка ошибок: неисправна. С точки зрения пользователя счет-фактура показывал статус «Оплачено» примерно 400 миллисекунд, после чего без каких-либо пояснений вернулся статус «Неоплачено». С технической точки зрения это соответствует понятию «автоматический откат». На практике такой механизм непригоден для использования в реальном продукте. Если единственное, что вы извлекли из документации, — это фраза «происходит откат при сбое», то именно такой результат вы в итоге получите.
Я также отслеживал, перерисовывается ли компонент родительского сервера. Это не происходило — значение invoice.paid никогда не менялось. Возврат к исходному состоянию произошел исключительно из-за исчезновения оптимистичного слоя, а не из-за выполнения каких-либо операций обратной синхронизации. Нигде в клиентской части не было вызова setPaid(false). Параметр base оставался на том же месте, где был изначально, поэтому, как только оптимистичный слой исчез, интерфейс снова отображал его базовое значение. Вот весь механизм, и он имеет значение при рассмотрении сценария мягкой ошибки ниже.
Сценарий сбоя 2 — Мягкая ошибка { ok: false }, без выбрасывания исключения
Именно здесь команды часто сталкиваются с проблемами. Вместо того чтобы выбросить исключение, многие реализации возвращают структурированный результат, чтобы путь обработки ошибок имел соответствующий тип. Разумный выбор — если только клиентский код никогда не проверяет это возвращаемое значение, переход всё равно успешно завершается с точки зрения React.
// still the happy-tutorial handler
startTransition(async () => {
setOptimisticPaid(!optimisticPaid);
await togglePaid(invoice.id, !optimisticPaid); // returns { ok: false }
});
FAIL_MODE = 'soft'. Основной хранилище данных остаётся нетронутым. Функция revalidatePath никогда не вызывается. Действие завершается значением { ok: false, code: 'BIZ', message: 'Invoice locked' } — исключений не выбрасывается.
Чего можно ожидать, если полагаться на принцип «автоматического возврата к исходному состоянию при сбое»: интерфейс возвращается к исходному состоянию, поскольку мутация не удалась.
Замечено при использовании простого обработчика выше (5/5 запусков):
- Оптимистичное состояние меняется на Paid
falseres никогда не читалсяТаким образом, даже «правильно работающая» версия корректно возвращается к исходному состоянию. Лёгкая ошибка не приводит к тому, что оптимистичное значение останется в силе — поверхностное окно исчезает сразу после завершения операции, независимо от того, возникла ли ошибка. Акцент в документации на возникновении ошибок касается типичного случая, а не единственного. Любая операция, которая завершается без изменения базового состояния, вернётся к исходному значению.
Так откуда же берётся этот «призрачный» интерфейс?
Это происходит, когда обработчик пытается быть «умным», обновляя локальное базовое состояние каждый раз, когда обещание решается — например, если вы отражаете флаг оплаты в useState и устанавливаете его до проверки значения ok:
// the lie I actually shipped once
startTransition(async () => {
setOptimisticPaid(true);
const res = await togglePaid(id, true);
setLocalPaid(true); // always — "the action finished"
if (!res.ok) setError(res.message); // too late, base already moved
});
При использовании такого подхода (5/5 запусков):
- Кнопка остается в состоянии Оплачено даже после сбоя
- Под строкой может отображаться сообщение об ошибке
- Фоновый хранилище RSC по-прежнему содержит значение Неоплачено
- При следующем переходе или любой последующей повторной верификации строка возвращается в исходное состояние — что приводит к появлению фантомного состояния, сохраняющегося до тех пор, пока что-то не заставит обновить данные
Чтобы подвести итог по оценкам: мягкая ошибка без обновления локальной базы означает, что возврат к предыдущему состоянию работает, но пользователь не получает никакой обратной связи. Мягкая ошибка в сочетании с немедленным обновлением локальной базы приводит к появлению призрачного интерфейса. Именно второй случай позже отображается как режим сбоя номер 2 в таблице результатов. Настоящей проблемой является не сама форма { ok: false } — а считать «действие завершено» эквивалентным «действие прошло успешно».
Режим сбоя 3 — проверка с помощью Zod, структурированная ошибка, отсутствие выбрасывания исключений
FAIL_MODE = 'zod'. Структурно это соответствует режиму 2, только запускается иначе. Вызов safeParse завершается с ошибкой (или активируется ветка обработки ошибок), и действие возвращает { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }. Исключения не выбрасываются, и функция revalidatePath пропускается.
Этот случай заслуживает отдельного рассмотрения, поскольку команды обычно считают ошибки валидации по своей природе «безопасными» — они предсказуемы, типизированы и обрабатываются целенаправленно. Пользователи не воспринимают всю эту нюансировку. С их точки зрения, индикатор просто мигнул и затем перестал реагировать.
Замечено (5/5 запусков) с клиентом, который корректно обновляет базовое состояние только тогда, когда значение ok равно true:
- Появляется состояние «Оплачено», переход завершается, после чего состояние возвращается к «Неоплачено»
- Среднее количество отрисовок: 4
- Время, пока была видна метка «Оплачено»: примерно 400–420 мс
- Сообщение для пользователя: нет, если только код явно не использует ветку на основе значения
res
Ошибки валидации могут казаться более надежными, поскольку TypeScript обеспечивает соблюдение их структуры, но это не делает интерфейс лучше — скорее, они проявляются менее заметно. Поведение при откате остается идентичным, а «тишина» при отсутствии ошибок такая же. Если бы форма использовала useActionState и приводила в соответствие возвращаемое значение к своему state, сообщение ошибка могло бы сохраниться во время перехода. Обычный компонент строки в стиле учебника этого не делает.
Стоит уточнить один момент: выполнение проверки с помощью Zod на клиенте до вызова setOptimistic предотвратило бы отображение состояния Paid. Режим 3 предназначен именно для обработки ошибок валидации на серверной стороне, возникающих после того, как уже произошло оптимистичное отображение. Именно такой порядок действий и приводит к сбоям.
Режим сбоя 4 — вызов addOptimistic вне функции startTransition
function onToggle() {
// 🚩 outside a Transition
setOptimisticPaid(!optimisticPaid);
startTransition(async () => {
await togglePaid(invoice.id, !optimisticPaid);
});
}
В документации предупреждается именно об этой ситуации: если вы обновляете оптимистичное состояние, не оформив это действие с помощью Transition или Action, изменение будет видно ненадолго, а затем почти мгновенно вернётся к исходному значению, поскольку отсутствует контекст перехода, который удерживал бы его на месте до завершения фоновых операций.
Без Transition, оборачивающего вызов, нет ничего, что могло бы сохранить прогнозируемое состояние во время выполнения асинхронных операций. У React нет контекста, к которому можно было бы привязать оптимистичное значение, поэтому оно просто возвращается к исходному состоянию.
Наблюдаемое поведение (5/5):
- Кратковременное изменение на состояние «Оплачено», обычно всего на одну кадровую проекцию, иногда — на две
- Немедленное возвращение к состоянию «Неоплачено» ещё до того, как истекут 400 мс с момента выполнения действия
- Предупреждение от React в DevTools при каждом клике
revalidatePath, происходит второе обновление при поступлении свежих данных с сервера, что приводит у пользователя к мгновенной реакции, за которой следует отложенное сохранение измененийЭто не откат, вызванный ошибкой. Это лучше описать как «состояние, которое никогда фактически не сохраняется». Режим сбоя 4 представляет собой ошибку в коде, а не проблему серверной части, но визуальный эффект такой же — мгновенная реакция, которую пользователь может приписать нестабильности системы. Он попадает в этот список, потому что является первым проблемным аспектом, возникающим при рефакторинге обработчика и перемещении функции setOptimistic выше функции startTransition ради более чистой структуры кода.
Режим сбоя 5 — успешное выполнение revalidatePath, но конфликт при двойном клике
Установите FAIL_MODE в значение 'race'. Инструмент тестирования выполняет два клика с интервалом в 50 мс. Оба вызывают переходы, оба оптимистично пытаются перевести статус в Paid. Первая запись завершается и повторно проверяется; вторая запись выполняется независимо.
Функция setPaid(id, paid) мок-магазина устанавливает абсолютное значение вместо того, чтобы изменить булевое поле в базе данных, поэтому настоящий дефект заключается в клиентской функции-закрытии:
setOptimisticPaid(!optimisticPaid);
await togglePaid(invdsoice.id, !optimisticPaid);
Если второй клик происходит достаточно быстро, значение optimisticPaid (или invoice.paid) внутри этой функции-закрытия будет либо тем же, что и до первого клика, либо значением, прочитанным в процессе выполнения из ещё не завершившейся оптимистичной обновления — исход зависит от точного времени. Одна из двух заявок в итоге отправляет значение paid: false.
Наблюдаемое поведение (5/5 при использовании устаревшей логики переключения):
- При первом клике отображается статус «Оплачено»
- При втором клике, примерно через 50 мс, в по крайней мере в 4 из 5 случаев отправляется неверное абсолютное значение
- Происходят два отдельных запуска операции обновления пути
revalidatePath - Конечное значение из слоя RSC показывает «Неоплачено», хотя пользователь сначала видел, как статус стал «Оплачено» — это эффект мгновенного изменения, за которым следует исчезающий результат
- Максимальное количество операций отрисовки в строке достигает 9: две операции оптимистичной отрисовки, две завершенные операции, два обновления данных из RSC плюс базовые операции отрисовки
Решение заключается в вычислении следующего значения на основе фиксированной отправной точки, связанной с намерением пользователя совершить клик, а не на основе того, что случайно хранится в переменных, а также в отключении функции переключения, пока optimisticPaid !== invoice.paid.
const next = !invoice.paid; // from base, not from a racing optimistic read
startTransition(async () => {
setOptimisticPaid(next);
const res = await togglePaid(invoice.id, next);
});
Если пропустить это исправление, фиктивный интерфейс останется даже при успешном выполнении — ничего не выбрасывается, Zod так и не запускается, но интерфейс всё равно вводит пользователя в заблуждение. Именно поэтому режим 5 должен находиться в колонке с фиктивным интерфейсом, а не в колонке с возвратом к предыдущему состоянию.
Табло результатов
mode | trigger | rollback | user error | final UI vs server | avg renders | score
-----|---------------------------------|----------|------------|--------------------|-------------|------
1 | throw Error (500-ish) | yes | none | match (Unpaid) | 4 | rollback OK / UX fail
2 | {ok:false} + eager base update | no* | maybe | GHOST (Paid lie) | 3 | ghost
3 | Zod structured error, no throw | yes | none | match (Unpaid) | 4 | rollback OK / UX fail
4 | setOptimistic outside transition| flash | warning | match after twitch | 3 | flash then revert
5 | revalidate + double-fire race | n/a | none | GHOST / flicker | 7–9 | ghost
* Mode 2 rolls back if you never touch base on failure. It ghosts if you set local/base on settle.
Three clean rollbacks: 1, 3, and 2-without-eager-base.
Two ghost paths: 2-with-eager-base, 5.
Mode 4 is a flash, not a held ghost — still a user-visible failure.
Утверждение подзаголовка, теперь подкреплённое цифрами: три режима сбоя приводят к возврату к предыдущему состоянию, а два оставляют фиктивный интерфейс. Режимы 1 и 3, а также режим 2 при тщательном обработке входят в группу с возвратом к предыдущему состоянию. Режимы 2-eager и 5 входят в группу с фиктивным интерфейсом. Режим 4 является исключением — он никогда не удерживает оптимистичный слой достаточно долго, чтобы можно было однозначно отнести его к какой-либо из категорий.
Исправленная версия: обработка ошибок, выживание во время перехода, факультативное использование actionState
Откат уже функционировал в случаях возникновения ошибок. Недоставало ошибки, которая оставалась бы активной после завершения перехода, а также базового значения, которое обновлялось бы только тогда, когда результат действительно равен ok: true.
// app/invoices/invoice-row.tsx — fixed
'use client';
import {
useOptimistic,
useState,
useTransition,
useRef,
} from 'react';
import { togglePaid, type ToggleResult } from './actions';
import type { Invoice } from '@/lib/invoices';
export function InvoiceRow({ invoice }: { invoice: Invoice }) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
const renders = useRef(0);
renders.current += 1;
const pending = optimisticPaid !== invoice.paid || isPending;
function onToggle() {
const next = !invoice.paid; // absolute next from server base
setError(null);
startTransition(async () => {
setOptimisticPaid(next);
try {
const res: ToggleResult = await togglePaid(invoice.id, next);
if (!res.ok) {
// transition will end; base unchanged → automatic revert
// error state is plain useState → survives the revert
setError(res.message);
return;
}
// success: revalidatePath in the action updates invoice.paid
} catch (e) {
setError(e instanceof Error ? e.message : 'Toggle failed');
}
});
}
return (
<li data-renders={renders.current}>
<span>{invoice.number}</span>
<button
type="button"
onClick={onToggle}
disabled={pending}
aria-pressed={optimisticPaid}
aria-busy={pending}
>
{optimisticPaid ? 'Paid' : 'Unpaid'}
</button>
{error ? (
<p role="alert" className="row-error">
{error}
<button type="button" onClick={onToggle}>
Retry
</button>
</p>
) : null}
</li>
);
}
Вот что на самом деле изменилось:
setOptimisticPaidтеперь вызывается только внутриstartTransition, что полностью устраняет режим 4.nextвычисляется на основе значенияinvoice.paid, уже сохранённого в памяти, а не на основе потенциально устаревшего оптимистичного значения, что снижает риски для режима 5.- Управление отключается с помощью параметра
disabled={pending}, когда значения оверлея и базового значения расходятся, что предотвращает ситуацию двойного клика. - Случай возникновения ошибки оборачивается блоком
try/catch, поэтому в режиме 1 теперь отображается сообщение после выполнения отката.
res.ok равен ложному значению, обновляется только состояние ошибки, основное состояние остается нетронутым, поэтому режимы 2 и 3 возвращаются к предыдущему состоянию, при этом объясняется причина такого изменения.useState, а не внутри оптимистичного значения, поэтому она сохраняется даже после устранения надписи с ошибкой.Шестой пункт потребовал дополнительного анализа для полного понимания. Если поместить ошибку внутрь оптимистичного редьюсера, она исчезнет сразу после завершения действия — при возврате к предыдущему состоянию будет удалена и собственная сообщение об ошибке вместе со старым интерфейсом. Обычный useState (или состояние, возвращаемое useActionState) является каналом, который продолжает существовать даже после исчезновения надписи с ошибкой.
Необязательно: использование useActionState для версии в формате таблицы
Если переключатель реализован как <form action>, вы можете позволить функции useActionState передавать последний результат во время перехода, вместо того чтобы вручную управлять этим состоянием:
'use client';
import { useOptimistic, useActionState } from 'react';
import { togglePaidForm, type ToggleResult } from './actions';
import type { Invoice } from '@/lib/invoices';
const initial: ToggleResult | null = null;
export function InvoiceRowForm({ invoice }: { invoice: Invoice }) {
const [optimisticPaid, setOptimisticPaid] = useOptimistic(invoice.paid);
const [state, formAction, pending] = useActionState(
async (_prev: ToggleResult | null, formData: FormData) => {
const next = formData.get('next') === 'true';
setOptimisticPaid(next); // form action is already an Action
return togglePaidForm(String(formData.get('id')), next);
},
initial,
);
return (
<form action={formAction}>
<input type="hidden" name="id" value={invoice.id} />
<input type="hidden" name="next" value={String(!invoice.paid)} />
<button type="submit" disabled={pending} aria-pressed={optimisticPaid}>
{optimisticPaid ? 'Paid' : 'Unpaid'}
</button>
{state && !state.ok ? (
<p role="alert">{state.message}</p>
) : null}
</form>
);
}
Правила не меняются. Функция-установщик по-прежнему выполняется внутри действия. Базовое значение по-прежнему обновляется только после успешной повторной верификации. Последняя ошибка по-прежнему сохраняется в state после исчезновения надписи. Используйте эту схему, когда элемент управления по своей природе является формой; версию с кнопкой и useTransition оставьте для компактных строк таблицы.
Результаты после применения исправления
Те же пять способов сбоя, по пять попыток каждый, были снова протестированы на исправленной строке.
mode | after fix | ghost? | avg renders
-----|--------------------------------------------------------|--------|------------
1 | rollback + role="alert" with thrown message | no | 4
2 | rollback + "Invoice locked" stays visible | no | 4
3 | rollback + "Invalid toggle payload" stays visible | no | 4
4 | eliminated (setter only in transition / form action) | no | n/a
5 | button disabled while pending; absolute next value | no* | 3–4
* Pathological manual double-submit via Playwright force-click still managed one flicker in 1/5 trials when I removed disabled. With disabled left on: 0/5 ghosts.
При успешном выполнении отображается в среднем 3 раза — сначала происходит монтирование, затем оптимистичное отрисовывание, после чего производится синхронизация RSC. Когда ошибка сопровождается сообщением, количество отображений увеличивается до 4 — монтирование, оптимистичное отрисовывание, откат, а затем отрисовка ошибки. Именно это четвертое отображение является стоимостью, которую приходится платить за такую строку таблицы.
Из пяти искусственно созданных ошибок три откатываются автоматически. Две все равно оставляют «призрачный» интерфейс: это мягкая ошибка, которая быстро обновляет базу данных, и ситуация с двойным запуском операций.
Трек отрисовки из режима 1
Здесь приведены необработанные значения функции performance.now(), зафиксированные во время третьего теста режима 1 при отключенном строгом режиме и с монтированной одной строкой.
0.0 click
2.1 optimistic commit — label=Paid, renders=2
401.8 action throw
403.2 transition end — label=Unpaid, renders=3
403.9 setError in fixed build — renders=4, alert visible
Если запустить тот же тест с поврежденной версией учебника, он застревает на уровне renders=3, при этом не отображается никакого уведомления. Именно эта четвертая операция отрисовки определяет разницу между работоспособным компонентом и сломанным. Само возврат к предыдущей версии никогда не было сложной частью — сложность заключалась в поддержании активности канала состояния после исчезновения оптимистичного слоя.
Эта четвертая операция отрисовки важнее, чем сокращение времени выполнения оптимистичного пути на несколько миллисекунд. Пользователь сможет простить ошибочное отображение метки в течение 400 мс, если интерфейс объяснит причину. Однако он не потерпит ситуацию, когда метка лживо отображается, а затем незаметно сама себя исправляет позже, пока кто-то не спросит об этом во время совещания.
Выводы для следующего pull request
useOptimistic предоставляет временный слой, действующий только до завершения перехода. Если операция вызывает ошибку и базовые данные так и не обновляются, React восстанавливает интерфейс. Мягкий ответ { ok: false } без обновления базовых данных приводит к аналогичному восстановлению. Оба поведения соответствуют описанию в документации, и оба были подтверждены здесь путем прямых тестов.
Документация не предоставляет автоматически:
- Читаемое сообщение для пользователя после восстановления состояния
- Защиту от мягкого ответа
{ ok: false }, если вы всё равно обновляете базовые данные - Защиту от вызова функции-установщика вне границ перехода
- Идемпотентное управление при быстрых двойных кликах в сочетании с
revalidatePath
Автоматическое возврат к предыдущему состоянию работает так, как заявлено. Хорошее обработчик ошибок стоит денег. Три из пяти намеренно сломанных случаев восстановились сами по себе; остальные два продолжали показывать устаревшее интерфейсное представление, пока фраза «действие завершено» перестала считаться синонимом фразы «действие прошло успешно».
Короткий чек-лист, который стоит вставить при ревью кода:
- Выполняется ли функция
setOptimisticвнутриstartTransitionили через свойствоactionформы? - Обновляется ли базовое состояние (или его локальная копия) только после подтверждения значения
res.ok, или после успешного выполнения без выброса исключений, которое также запускает повторную верификацию? - Находится ли ошибка в состоянии, управляемом через
useState, или в состоянии, управляемом черезuseActionState, отдельно от оптимистичного редьюсера? - Рассчитывается ли следующее значение на основе базового состояния сервера, при этом управление отключается пока действие находится в ожидании?
{ ok: false } в браузере, а не только функцию переключения «хэппи-пат»?Если пример в учебном пособии останавливается на вызове setOptimistic и ожидании действия, это означает, что представлена версия с тихой ошибкой. Необходимо перехватить исключение, изучить его результат и сохранять ошибки в useState или useActionState. Отключайте элемент управления каждый раз, когда данные на экране и данные с сервера не совпадают. Если делать это, функция отката, уже предоставляемая React, становится чем-то, с чем реальный пользователь действительно может справиться.
Связанная литература
- Отладка Server Actions в Next.js: ошибки развертывания, CORS и ограничения объема данных — практическое руководство по устранению неполадок, объясняющее, почему Server Actions и API-маршруты в Next.js работают без ошибок или выдают неясные сообщения об ошибках, с конкретными решениями для каждого случая.
- Фронтенд в 2027 году: серверная первичная отрисовка, TypeScript и стандарты Edge — подробный обзор того, как фреймворки с серверной первичной отрисовкой, обязательное использование TypeScript, кодирование с помощью ИИ и отрисовка на сервере преобразуют практики разработки фронтенда.