Галоўная / Артыкулы / useOptimistic Rollback: Пяць спосабоў неудачы ў Next.js Server Actions

useOptimistic Rollback: Пяць спосабоў неудачы ў Next.js Server Actions

Дазвольце даклэ расказаць, чаму прыемка 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. Чыслы, якія прыводзяцца далей, апраносяць самэй гэтай средзе. Якщо вы запусціце той самы працэўны каскад на іншай машыне, час адпаведных дзеянняў можа незначна змяніцца, але форма кожнага браку застаецца той самай.

Што насправды обяцваюць дакументацыі

Oficыйная сторонка-джерела для useOptimistic чытка адзначае: оптымістычныя значэння паказваюцца толькі тады, калі дзеянне ўсё яшчэ выпрацоўванае; калі ёна завершыцца, React перайдзе на атрыбут value, які ў тым часе існуе.

У яго таксама пояснюецца, што выходзіць, калі ўсё йдzie не так. Корача кажучы: неканфіскованая памялка ўнутрь Action усё равна дазволяе запусканым Transition завершыцца нормальна. Пакалі асупраўнік коду зазвычай запішвае значэння ў справжній value толькі пасля успешнага вызову, памялка означае, што гэта значэнне так і не было зменена — таму, калі Transition завершыцца, React проста пакажае той жа інтерфейс, які відвідчыў корыстнік да таго, як адбулася кліка. У документах зазначаецца, што якщо вы хочаце паказаць корыстніку якое-небудзь паведамленне, вам трэба самім канфісаваць гэтую памялку; React гэта зробіць за вас не будзе.

Тут таксама маюць значэння два дадатковыя моменты:

  1. Оптымістычны setter павінен выконвацца ўнутрь Action або ўнутрь startTransition. Якщо ён выконваецца паўтра, React фіксуе паведамленне пра адзінак, і оптымістычны інтерфейс з’яўляецца толькі на короткі час, перш чым знікае.
  • Атрыбут «rollback» не ўвядзаецца спецыяльна — гэта проста тое, што выканаецца за замовчаннем, калі пераход завершыўся, а базовая значэнне так і не было зменена.
  • Гэты другі момент ёсць справжней сутнёй усіго гэтага. Адкрэцанне UI адбываецца без додатковых зусиль. Чаму гэта робіцца, вам трэба паведаміць корыстніку. Функцыя «hook» паказвае прыбліжанае значэнне пакуль дзейна Акцыя, а потым супрацоўвае з рэальным значэннем у роднікавай структуре. Якщо вы виконаеце дзія без змены гэтага базовага значэння, адбудзецца атрыбут «rollback». Якщо дзія будзе выкааная з успехам без змены значэння, таксама адбудзецца атрыбут «rollback». Якщо дзія будзе выкааная з успехам, але базовая значэнне будзе апцэнаваная з некоректным рынкам, тады стварыцца «праэктычны» стан — UI, який паказвае тое, чаго на самай справе ніколи не было на серверы.

    Міні-прыклад: пераключальнік па сплатаце рахунку

    У заместо простаг прыклада, тэставыя аплікацыя імітуюць экран вырахунку, таму што самэй тут некоректны знак „Заплацана“ прыводзіць да тэлефоннага дзвянка з аддзелу выеіскаўання боргаў.

    У такі спосаб разбіваецца яго структура:

    • Сторанка 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 props, useOptimistic і адна Server Action.

    Компонент рядка «happy path» выглядае як што-небудзь з будзь-якага вступнага туторыялу:

    // 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)
  • Відобразжаемая для пользователя паведамленне пра абэрасцію: немае
  • Рэзультаты выводу ў консолі: абэрасць сервернай дзеяння, якая не была адрабоўвана; у режыме разработкі яна паказваецца як чырвоныя рамкі 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
  • Пераход завершаецца нормальна (обявленне выпало)
  • Значэння базовага параметра застаецца false
  • Надпіс знікае пасля завершэння пераходу, таму пазначка знову становіцца «Неплатыяна»
  • Серэднія колькасць відрасоблення: 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 могла б запобiec адобразэнню стану 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 мс
    • Апавешчэнне з боку React у DevTools пасля кожнага кліка
  • Колькісна калькуляцыя: 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 з застарэлай логікаю переключэння):

    • Першы ўдар па кнопцы выканае прызначэнне «Заплачана»
    • Другі ўдар, прыблізна за 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>
      );
    }
    

    Ось што на самай працэ практычна змянілася:

    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 мс, якшо інтэрфейс поясніць прычыну. Але ён не будзе толькі прыняць пазначэнне, яке каламутна дае некоректную інфармацыю, а потым тыха сама сабе які-то час пасля цього выправляецца, не прывертаючы увагі, пакуль хтось не запытае пра гэта пад час нарады.

    Выводы для наступнага pull request

    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, стае чымось, з чым справжні корыстувач можа рэальна жыць.

    Спадні матэрыялы

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