useOptimistic Rollback: П’ять способів збою в Server Actions 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 це не зробить за вас.
Тут мають значення ще два деталі:
- Оптимістичний setter має виконуватися всередині 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). Основна властивість залишилася точно там, де була спочатку, тож як тільки оптимістичний шар зник, інтерфейс просто знову відобразив початкове значення. Ось весь механізм, і це має значення, коли мова йде про випадок „м’якої“ помилки нижче.
Режим помилки 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 запусків):
- Кнопка залишається у стані Paid навіть після збою
- Повідомлення про помилку може відображатися під рядком
- Основний сховище RSC все ще містить значення Unpaid
- Наступне перемикання або будь-яка подальша перевірка повертає рядок у попередній стан — що створює фантомний стан, який зберігається до тих пір, поки щось не змусить оновити дані
Щоб підсумувати оцінку: м’яка невдача без оновлення локальної бази означає, що механізм відкату працює, але користувач не отримує жодних повідомлень. М’яка невдача у поєднанні з примусовим оновленням локальної бази призводить до появи фантомного інтерфейсу. Саме цей другий випадок згодом відображається як режим невдачі номер 2 у табелі оцінок. Справжня проблема не в самій формі { ok: false } — а у тому, що «дія завершена» сприймається як еквівалент «дія вдалась».
Режим невдачі 3 — перевірка за допомогою Zod, структурована помилка, відсутність кидків
FAIL_MODE = 'zod'. Структурно це відповідає режиму 2, просто запускається інакше. Виклик safeParse зазнає невдачі (або активується відповідний режим невдачі), і дія повертає значення { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }. Не кидається жодна виняток, і функція revalidatePath проходить повз.
Цей випадок заслуговує на окремий розділ, оскільки команди зазвичай сприймають помилки валідації як щось „безпечне“ — вони передбачувані, типові та обробляються навмисно. Користувачі не бачать усіх цих нюансів. З їхньої точки зору, індикатор просто мерехтів, а потім замовк.
Спостережувалося (5/5 запусків) з клієнтом, який правильно оновлює базовий стан лише тоді, коли ok дорівнює true:
- З’являється стан „Optimistic Paid“, перехід завершується, після чого стан знову стає „Unpaid“
- Середня кількість відображень: 4
- Тривалість видимості позначки „Paid“: приблизно 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):
- Швидкий моментальний перехід у стан Paid, зазвичай лише одна кадр, іноді два кадри
- Негайне повернення у стан Unpaid, що відбувається ще до того, як завершиться дія на 400 мс
- Попередження в DevTools у React після кожного кліку
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 зі старою логікою перемикання):
- Перший клік робить статус «Op paid»
- Другий клік, приблизно через 50 мс, надсилає неправильне абсолютне значення принаймні у 4 з 5 спроб
- Відбуваються два окремі запуски функції
revalidatePath - Остаточне значення з шару RSC відображається як Unpaid, хоча користувач спостерігав, як статус спочатку став «Op paid» — це ефект миттєвої зміни та подальшого зникнення значення
- У найгіршому випадку кількість операцій відображення на цьому рядку досягає 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 дорівнює false, оновлюється лише стан помилки, базовий стан залишається незмінним, тому режими 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>
);
}
Правила не змінюються. Функція встановлення даних все ще виконується всередині Action. Базове значення все ще збільшується лише після успішної перевірки. Остання помилка все ще зберігається в 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 мс, якщо інтерфейс пояснить причину. Вони не пробачать текст, який бреше, а потім тихо сам себе виправляє пізніше, непомітно, доки хтось не запитає про це під час наради.
Висновки для наступної заявки на пул-ріквест
useOptimistic надає вам тимчасову покривку, яка існує лише до завершення переходу. Якщо операція викликає помилку та базові дані так і не оновлюються, React повертає UI у початковий стан. М’яка відповідь { ok: false } без оновлення базових даних також призводить до повернення у початковий стан. Обидві поведінки відповідають тому, що описано в документації, і обидві були підтверджені тут шляхом прямих тестів.
Те, що документація не надає автоматично:
- Читабельне повідомлення для користувача після повернення стану у початковий формат
- Захист від м’якої ситуації
{ ok: false }, якщо ви все одно оновлюєте базові дані - Захист від виклику функції-встановлювача поза межами переходу
- Ідемпотентне перемикання під час швидких подвійних кліків у поєднанні з
revalidatePath
Автоматичне скасування працює так, як описано. Хороше оброблення помилок не є безкоштовним. Три з п’яти навмисно пошкоджених випадків автоматично повернулися до нормального стану; ще два продовжували відображати застарілий інтерфейс, поки фраза „дія завершена“ була розглядана як синонім фрази „дія вдалась“.
Короткий перелік, який варто додати під час перевірки коду:
- Чи виконується
setOptimisticвсерединіstartTransition, чи через властивістьactionформи? - Чи оновлюється базовий даний (або його локальна копія) лише після підтвердження
res.ok, чи й після успішного виконання без кидків, що також спричиняє перевірку даних? - Чи знаходиться помилка у
useStateчи уuseActionState, окремо від оптимістичного редуктора? - Чи обчислюється наступне значення з базового стану сервера, при цьому керування вимкнене поки дія ще триває?
{ ok: false }, а не лише функцію перемикання «успішного шляху»?Якщо приклад у навчальному посібнику закінчується лише викликом setOptimistic та очікуванням дії, це означає, що надана версія має приховану помилку. Перехопіть виняток, перевірте результат та зберігайте помилки у useState або useActionState. Вимкніть керування щоразу, коли інтерфейс та сервер не погоджуються. Якщо робити це, то механізм відкату, який вже надає React безкоштовно, стане чимось, з чим справжній користувач зможе ефективно працювати.
Пов’язані матеріали
- Відлагодження Next.js Server Actions: проблеми з розгортанням, CORS та обмеження навантажень — практичний посібник з усунення проблем, який пояснює, чому Next.js Server Actions та API-шляхи працюють без помітних наслідків або викидають неzрозумілі помилки, та пропонує конкретні рішення для кожного випадку.
- Фронтенд у 2027 році: серверно-орієнтоване відображення, TypeScript та стандарти Edge — детальний огляд того, як фреймворки з серверно-орієнтованим підходом, обов’язкове використання TypeScript, кодування за допомогою ШІ та серверне відображення змінюють практики розробки фронтенду.