Головна / Статті / useOptimistic Rollback: П’ять способів збою в Server Actions Next.js

useOptimistic Rollback: П’ять способів збою в Server Actions Next.js

Дізнайтеся, чому useOptimistic тихо скасовує інтерфейс користувача, не пояснюючи користувачам причини збоїв, через п’ять перевірених режимів збоїв Server Action та ефективне рішення проблеми.

4375 слів

Автоматичне скасування дії працює саме так, як обіцяно. Однак відображення повідомлення про помилку перед користувачем — ні. Я навмисно активував п’ять різних сценаріїв збою у функції 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 це не зробить за вас.

Тут мають значення ще два деталі:

  1. Оптимістичний 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 мс: позначка знову змінюється на «Неплатежний»
  • Середня кількість виконань для цього рядка: 4 (початкове завантаження, оптимістичне оновлення, скасування змін, а потім тихе виконання RSC)
  • Повідомлення про помилку, видиме для користувача: жодного
  • Вивід у консолі: помилка Server Action, яку не вдалося обробити; у режимі розробки це відображається як червона рамка Next.js
  • Поведінка під час скасування змін: функціонує згідно з документацією. Обробка помилок: не працює. З точки зору користувача, статус рахунку-фактури спочатку показував «Оплачено» протягом приблизно 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
  • Перехід завершується нормально (обіцянка виконується)
  • Параметр base залишається false
  • Накладка зникає після завершення переходу, тож позначка знову стає Unpaid
  • Середня кількість відображень: 4
  • Повідомлення для користувача: все ще жодного, оскільки повернений res так і не був прочитаний
  • Отже, навіть „правильно працююча“ версія правильно скасовує зміни. Легка помилка не призводить до того, що оптимістичне значення залишиться саме по собі — накладка зникає щоразу, коли дія завершується, незалежно від того, чи виникла помилка. Наголос у документації на виникненні помилок стосується типового випадку, а не єдиного. Будь-яка дія, яка завершується без зміни базового стану, буде скасована.

    То звідки насправді береться цей „привид“ у інтерфейсі?

    Це виникає тоді, коли обробник намагається бути „розумним“, оновлюючи локальний базовий стан щоразу, коли обіцянка виконується — наприклад, якщо ви відображаєте статус оплати через 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 після кожного кліку
  • Кількість відтворень: 3 (початкове завантаження, спалах, скасування), пізніше — оновлення через RSC після успішного виконання дії
  • У успішному сценарії, як тільки виконується 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>
      );
    }
    

    Ось що насправді змінилося:

    1. setOptimisticPaid тепер викликається лише всередині startTransition, що повністю усуває режим 4.
    2. next обчислюється на основі invoice.paid, тобто збереженого значення, а не на основі можливо некоректного оптимістичного значення, що зменшує ризики для режиму 5.
    3. Контроль відключається за допомогою disabled={pending}, коли оверлей та базове значення відрізняються, що запобігає конфліктам від подвійного клацання.
    4. Ситуацію з виникненням помилок обгортає блок 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

    Автоматичне скасування працює так, як описано. Хороше оброблення помилок не є безкоштовним. Три з п’яти навмисно пошкоджених випадків автоматично повернулися до нормального стану; ще два продовжували відображати застарілий інтерфейс, поки фраза „дія завершена“ була розглядана як синонім фрази „дія вдалась“.

    Короткий перелік, який варто додати під час перевірки коду:

    1. Чи виконується setOptimistic всередині startTransition, чи через властивість action форми?
    2. Чи оновлюється базовий даний (або його локальна копія) лише після підтвердження res.ok, чи й після успішного виконання без кидків, що також спричиняє перевірку даних?
    3. Чи знаходиться помилка у useState чи у useActionState, окремо від оптимістичного редуктора?
    4. Чи обчислюється наступне значення з базового стану сервера, при цьому керування вимкнене поки дія ще триває?
  • Чи хтось справді використовував у браузері як шлях кидання, так і шлях { ok: false }, а не лише функцію перемикання «успішного шляху»?
  • Якщо приклад у навчальному посібнику закінчується лише викликом setOptimistic та очікуванням дії, це означає, що надана версія має приховану помилку. Перехопіть виняток, перевірте результат та зберігайте помилки у useState або useActionState. Вимкніть керування щоразу, коли інтерфейс та сервер не погоджуються. Якщо робити це, то механізм відкату, який вже надає React безкоштовно, стане чимось, з чим справжній користувач зможе ефективно працювати.

    Пов’язані матеріали

  • Чому Server Actions у Next.js потребують авторизації всередині кожного тіла функції — Детальний аналіз випадку захоплення облікового запису показує, як Server Actions у Next.js без автентифікації дозволяють виконувати привілейовані операції, та де має знаходитися перевірка авторизації, щоб це запобігти.
  • П’ять захисних механізмів фронтенду, які потрібні кожному додатку на React та Next.js — Чому продакшн-додатки на React та Next.js використовують куки типу HttpOnly, CSP, DOMPurify, заголовки безпеки та правила NEXT_PUBLIC_, і які атаки кожен з них блокує.