Strona główna / Artykuły / useOptimistic Rollback: Pięć trybów awarii w Next.js Server Actions

useOptimistic Rollback: Pięć trybów awarii w Next.js Server Actions

Dowiedz się, dlaczego useOptimistic w tajemnicy przywraca interfejs użytkownika, nie wyjaśniając użytkownikom przyczyn błędów, poprzez pięć sprawdzonych trybów awarii Server Action oraz skuteczną metodę naprawy.

4375 słów

Automaticzne cofnięcie działa dokładnie tak, jak obiecano. Pokazywanie komunikatu o błędzie użytkownikowi nie działa w ten sam sposób. Celowo uruchomiłem pięć różnych scenariuszy awarii w elementzie Server Action w Next.js i odnotowałem to, co faktycznie pojawiło się na ekranie.

Cofnięcie nie kosztuje nic. Komunikowanie o awarii natomiast tak. Stan płatny zmienia się, a następnie wraca w tajemnicy — bez żadnego wyjaśnienia, które użytkownik mógłby odczytać.

Większość tutoriali traktuje useOptimistic jak darmową funkcję cofania. Klikasz, interfejs się zmienia, żądanie zawodzi, a interfejs wraca do stanu początkowego. To wszystko.

Chciałem sprawdzić, czy ta obietnica się sprawdzi, gdy sytuacja stanie się skomplikowana. Dlatego stworzyłem małą listę faktur w Next.js App Router — pięć wierszy, z których każdy był połączony ze swoją własną Server Action — i w rzeczywistym kodzie wypróbowałem pięć różnych sposobów, w jaki ta akcja może zawieść. Nie chodziło o sztucznie wymyślone przypadki krawędziowe, lecz o błędy, które przedostają się do środowiska produkcyjnego, a nikt ich nie zauważa.

Przy pięciu próbach dla każdego trybu awarii, w trzech przypadkach interfejs został poprawnie cofnięty. W dwóch pozostało ono z pokazanymi błędnymi danymi. Automatyczne cofnięcie działa, gdy akcja wywołuje błąd. Nie działa natomiast wtedy, gdy akcja po cichu zwraca coś takiego jak { ok: false } zamiast wywołać błąd — taki wzorzec to subtelny sposób na wprowadzenie użytkownika w błąd bez żadnych intencji.

Dla referencji, zostało to stworzone na bazie wersji 15.5.2 Next.js, w połączeniu z React 19.1.1, skompilowane za pomocą TypeScript 5.9.2 i uruchomione na systemie Linux z wersją Node 22.x. Liczby podane poniżej pochodzą z dokładnie takiego środowiska. Jeśli uruchomisz ten sam zestaw narzędzi na innej maszynie, czasy mogą się nieznacznie zmienić, ale charakterystyka każdego błędu powinna pozostać taka sama.

Co faktycznie obiecują dokumentacje

Oficjalna strona referencyjna dla useOptimistic jasno stwierdza, że wartość optymistyczna jest wyświetlana tylko wtedy, gdy akcja jest jeszcze w trakcie wykonywania; po jej zakończeniu React przechodzi na renderowanie wartości, którą obecnie ma rzeczywisty value.

Tłumaczy to również, co się dzieje, gdy coś idzie nie tak. Krótko mówiąc: błąd, którego nie udało się złapać wewnątrz Action, nadal pozwala na normalne zakończenie trwającej Transition. Ponieważ otaczający kod zazwyczaj zapisuje wartość do rzeczywistego value dopiero po udanej próbie wywołania, błąd oznacza, że ta wartość nigdy nie została zmieniona — więc po zakończeniu Transition React po prostu wyświetla tę samą interfejs użytkownika, którą widział przed kliknięciem. W dokumentacji zaznaczono, że należy samodzielnie złapać taki błąd, jeśli chce się pokazać użytkownikowi jakąkolwiek informację; React tego nie zrobi za nas.

Tutaj istotne są jeszcze dwa dodatkowe szczegóły:

  1. Optymistyczny setter musi być uruchamiany wewnątrz Action lub wewnątrz startTransition. Jeśli zostanie uruchomiony poza nimi, React zapisuje ostrzeżenie, a optymistyczny interfejs pojawia się tylko na krótko, zanim znika.
  • Odwrócenie zmian nie jest czymś, co uruchamiasz – to po prostu to, co dzieje się domyślnie, gdy przejście zostanie zakończone, a podstawowa wartość nigdy nie została zmieniona.
  • To drugi punkt stanowi właściwie sedno całej tej koncepcji. Odwrócenie stanu interfejsu następuje automatycznie. To ty decydujesz, dlaczego to robisz i co powiedzieć użytkownikowi. Element hook wyświetla przewidywaną wartość tak długo, jak akcja jest w toku, a następnie dostosowuje się do rzeczywistej wartości elementu nadrzędnego. Jeśli wywołasz akcję bez zmiany tej podstawowej wartości, dojdzie do odwrócenia zmian. Jeśli akcja zostanie zakończona pomyślnie bez żadnych zmian, również nastąpi odwrócenie zmian. Jeśli akcja zostanie zakończona pomyślnie, ale podstawowa wartość zostanie zaktualizowana błędnym wynikiem, powstaje tzw. stan widmowy – interfejs pokazujący coś, co tak naprawdę nigdy nie wydarzyło się na serwerze.

    Mini-aplikacja: przełącznik potwierdzenia zapłaty rachunku

    Zamiast prostego przykładu, aplikacja testowa symuluje ekran fakturowania, ponieważ właśnie tam błędna etykieta „Opłacone” może doprowadzić do telefonu od działu windykacji.

    Oto jak jest skonstruowana:

    • Strona RSC ładuje pięć faktur z pamięci lokalnej (INV-1001 do INV-1005), które na początku są wszystkie nieopłacone.
    • Każdy wiersz jest renderowany jako odrębny komponent klienta zawierający wartość logiczną typu boolean.
    • Kliknięcie przycisku uruchamia akcję serwera z wyraźną wartością booleanową paid.
    • revalidatePath('/invoices') jest wykonywany tylko wtedy, gdy akcja się powiedzie.
    • Każdy wiersz ma zliczacz renderowania, który wzrasta przy każdym odświeżeniu. Tryb ścisły jest wyłączony, aby ten zliczacz nie został zawyżony przez podwójne wywołania.
  • Działanie zawiera sztuczne await sleep(400), dzięki czemu okno optymistyczne jest wystarczająco długie, aby można je było obserwować wizualnie oraz zmierzyć za pomocą 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>
      );
    }
    

    Zasada dla każdego przeprowadzenia testu: sformatuj magazyn faktur jako nieopłacone, ustaw aktywny FAIL_MODE, kliknij przycisk raz (dwa razy w przypadku trybu piątego), poczekaj 800 ms po zakończeniu działania, następnie sprawdź etykietę przycisku, sprawdź data-renders, zanotuj wszelkie ostrzeżenia w konsoli i zrób skrynkotekst. Każdy tryb był wykonywany pięć razy, zawsze na tym samym identyfikatorze faktury, bez użycia React Query ani zewnętrznego warstwy cache’owania — tylko właściwości RSC, useOptimistic oraz pojedyncze Server Action.

    Komponent rzędu „happy path” wygląda jak coś z każdego wprowadzającego tutorialu:

    // 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>
      );
    }
    

    Oto sama akcja serwera, z przełącznikiem awarii, który zestaw testowy może aktywować według potrzeb:

    // 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 };
    }
    

    Tryb awarii 1 — akcja serwera rzuca wyjątek

    FAIL_MODE = 'throw'. Akcja rzuca wyjątek po 400 ms opóźnienia. Nic po stronie klienta tego nie łapie. To dokładny scenariusz omówiony w dokumentacji.

    Oczekiwane: przejście zostaje zakończone, wartość invoice.paid nie ulega zmianie, warstwa optymistyczna znika, a przycisk wraca do pokazywania stanu „Nieopłacone”.

    Zauważone (5 z 5 prób):

    • t=0ms: kliknięcie zostaje zarejestrowane, etykieta natychmiast zmienia się na „Opłacone” (działanie optymistyczne)
    • t≈400ms: pojawia się wyjątek, co kończy przejście
    • t≈410ms: etykieta wraca do stanu „Nieopłacone”
  • Średnia liczba renderowań wiersza: 4 (pierwotne załadowanie, optymistyczna aktualizacja, cofnięcie, a następnie cichy proces RSC)
  • Wiadomość o błędzie widoczna dla użytkownika: brak
  • Wyjście z konsoli: nierozwiązany błąd Server Action, wyświetlany jako czerwona ramka Next.js w trybie rozwojowym
  • Zachowanie przy cofnięciu: działa zgodnie z dokumentacją. Obsługa błędów: zawodna. Z punktu widzenia użytkownika faktura pokazywała stan „Opłacone” przez około 400 milisekund, a następnie wróciła do stanu „Nieopłacone” bez żadnego wyjaśnienia. Technicznie odpowiada to „automatycznemu cofnięciu”. W praktyce jest to niewykorzystywalne w rzeczywistym produkcie. Jeśli jedyne, co zaczerpnąłeś z dokumentacji, to informacja „cofnie się w przypadku błędu”, to właśnie taki efekt ostatecznie dostarczysz.

    Również sprawdziłem, czy komponent serwera nadrzędnego został ponownie wyrenderowany. Nie stało się to — invoice.paid w ogóle się nie zmieniło. Powrót do stanu pierwotnego nastąpił wyłącznie dlatego, że warstwa optymistyczna zniknęła, a nie dlatego, że wykonywano jakąś aktualizację odwrotną. W żadnym miejscu na stronie klienta nie było wywołania setPaid(false). Propozycja bazowa pozostała dokładnie tam, gdzie zaczęła, więc gdy warstwa optymistyczna zniknęła, interfejs po prostu ponownie wyświetlił wartość bazową. To jest cały mechanizm, a ma to znaczenie, gdy przejdziemy do przypadku łagodnego błędu poniżej.

    Tryb awarii 2 — Łagodny { ok: false }, bez rzucania błędu

    Tutaj zespoły najczęściej napotykają problemy. Zamiast rzucać wyjątkiem, wiele implementacji zwraca ustrukturyzowany wynik, dzięki czemu ścieżka błędu ma odpowiedni typ. To rozsądny wybór — chyba że kod klienta nigdy nie sprawdza tej wartości zwracanej, wtedy przejście nadal zakończa się pomyślnie z punktu widzenia Reacta.

    // still the happy-tutorial handler
    startTransition(async () => {
      setOptimisticPaid(!optimisticPaid);
      await togglePaid(invoice.id, !optimisticPaid); // returns { ok: false }
    });
    

    FAIL_MODE = 'soft'. Podstawowy magazyn danych pozostaje nietknięty. Funkcja revalidatePath nigdy nie jest wywoływana. Akcja kończy się wartością { ok: false, code: 'BIZ', message: 'Invoice locked' } — nie rzuca się żadnego wyjątku.

    To, czego można by się spodziewać, jeśli polega się na zasadzie „automatycznego cofnięcia w przypadku błędu”: interfejs użytkownika wraca do poprzedniego stanu, ponieważ mutacja nie zakończyła się pomyślnie.

    Zauważono przy powyższym prostym obsługiwaczu (5/5 prób):

    • Stan optymistyczny zmienia się na Paid
  • Przejście kończy się normalnie (obietnica zostaje spełniona)
  • Wartość bazowa pozostaje na poziomie false
  • Nadpis znika po zakończeniu przejścia, więc etykieta wraca do stanu „Niezapłacone”
  • Seria renderowań: 4
  • Wiadomość dla użytkownika: nadal brak, ponieważ zwrócona wartość res nigdy nie została odczytana
  • Zatem nawet wersja „dobrze zachowująca się” poprawnie wraca do stanu początkowego. Delikatny błąd nie powoduje, że wartość optymistyczna pozostanie sama – nadpis znika w momencie zakończenia operacji, niezależnie od tego, czy doszło do błędu. Nacisk w dokumentacji na wystąpienie błędu dotyczy przypadku typowego, a nie jedynego możliwego. Każda operacja, która zakończy się bez zmiany stanu bazowego, zostanie cofnięta.

    Zatem skąd właściwie pochodzi ten widoczny element interfejsu?

    Pojawia się to, gdy obsługa próbuje być „mądra”, aktualizując lokalny stan bazowy za każdym razem, gdy obietnica zostanie spełniona — na przykład jeśli odzwierciedlasz flagę płatności w useState i ustawiasz ją przed sprawdzeniem wartości 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
    });
    

    Z tym wzorcem wdrożonym (5/5 prób):

    • Przycisk pozostaje ustawiony na Paid nawet po niepowodzeniu
    • Wiadomość o błędzie może zostać wyświetlona poniżej wiersza
    • Podstawowy magazyn RSC nadal zawiera wartość Unpaid
    • Następna nawigacja lub późniejsza ponowna weryfikacja przywraca wiersz do poprzedniego stanu — co powoduje powstanie stanu widmowego, który utrzymuje się, dopóki coś nie zmusi do odświeżenia

    Aby podsumować ocenę: łagodna awaria bez aktualizacji lokalnej bazy oznacza, że procedura cofania działa, ale nie otrzymuje się żadnej informacji zwrotnej od użytkownika. Łagodna awaria w połączeniu z natychmiastową aktualizacją lokalnej bazy powoduje pojawienie się widma interfejsu. To właśnie ten drugi przypadek jest później pokazywany jako tryb awarii nr 2 na tabeli wyników. Prawdziwym defektem nie jest sama forma { ok: false } — chodzi o traktowanie „działanie zakończyło się” jako równoważnego „działanie zakończyło się pomyślnie”.

    Tryb awarii 3 — walidacja Zod, zstrukturyzowany błąd, brak rzucania wyjątku

    FAIL_MODE = 'zod'. Strukturalnie odpowiada to trybowi 2, tylko jest inicjowany w inny sposób. Wywołanie safeParse kończy się niepowodzeniem (lub jest zmuszany branie ścieżki awarii), a działanie zwraca { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' }. Nie jest rzucany żaden wyjątek, a funkcja revalidatePath jest pomijana.

    Ten przypadek zasługuje na osobny temat, ponieważ zespoły mają tendencję do traktowania błędów walidacji jako czegoś z natury „bezpiecznego” – są one przewidywalne, typowane i celowo radzone sobie z nimi. Użytkownicy nie dostrzegają tej subtelności. Z ich punktu widzenia przycisk po prostu zamigotał, a potem ucichł.

    Zauważone (5/5 prób) przy klientzie, który poprawnie aktualizuje tylko stan bazowy, gdy ok ma wartość prawdziwą:

    • Pojawia się stan „Optimistic Paid”, przejście się kończy, a następnie system wraca do stanu „Unpaid”
    • Srednia liczba renderowań: 4
    • Czas, przez jaki widoczna była etykieta „Paid”: około 400–420 ms
    • Wiadomość dla użytkownika: żadna, chyba że kod wyraźnie rozgałęzia się w zależności od wartości res

    Błędy walidacji mogą wydawać się bardziej wiarygodne, ponieważ TypeScript narzuca określony kształt tych błędów, ale to nie oznacza lepszej interfejsu — wręcz przeciwnie, jest on cichszy. Procedura cofania się zachowuje się identycznie, a cisza pozostaje taka sama. Gdyby formularz używał useActionState i mapował zwróconą wartość do swojego state, komunikat mógłby przetrwać tę transformację. Zwykły komponent w stylu tutorialu tego nie robi.

    Należy zaznaczyć jedną kwestię: wykonywanie walidacji Zod na stronie klienta przed wywołaniem setOptimistic zapobiegłoby pokazaniu stanu Paid. Tryb 3 dotyczy konkretnie walidacji po stronie serwera, która zawodzi po faktycznym wyświetleniu treści w trybie optymistycznym. Właśnie ta kolejność powoduje ten błąd.

    Tryb awarii 4 — wywoływanie addOptimistic poza startTransition

    function onToggle() {
      // 🚩 outside a Transition
      setOptimisticPaid(!optimisticPaid);
      startTransition(async () => {
        await togglePaid(invoice.id, !optimisticPaid);
      });
    }
    

    Dokumentacja ostrzega właśnie przed taką sytuacją: jeśli zaktualizujesz stan optymistyczny bez umieszczenia go w strukturze Transition lub Action, zmiana pojawi się na chwilę, a następnie niemal natychmiast wróci do swojej pierwotnej wartości, ponieważ nie ma żadnego kontekstu przejścia, który utrzymałby ją na miejscu podczas wykonywania operacji.

    Braćcą braku struktury Transition otaczającej wywołanie, nie ma nic, co utrzymałoby tę prognozę w życiu podczas wykonywania zadań asynchronicznych. React nie ma żadnego kontekstu, do którego mógłby przypiąć wartość optymistyczną, więc ona po prostu wraca do poprzedniego stanu.

    Zauważone (5/5):

    • Szybki błysk na kolor „Paid”, często tylko jeden kadru, czasami dwa rysowania
    • Niemal natychmiastowe powrót do stanu „Unpaid”, zanim nawet zadanie trwające 400 ms zostanie zakończone
    • Ostrzeżenie React w DevTools przy każdym kliknięciu
  • Liczba renderowań: 3 (pierwotne załadowanie, efekt błysku, odwrócenie), a później odświeżenie RSC po pomyślnym zakończeniu operacji
  • W przypadku pomyślnego przebiegu, po wykonaniu funkcji revalidatePath następuje druga zmiana, gdy przychodzą aktualne dane serwera, co powoduje chwilowe drgnienie interfejsu, a następnie opóźnione zatwierdzenie zmian
  • To nie jest cofnięcie spowodowane błędem. Lepiej to opisać jako „nigdy faktycznie nie utrzymywane”. Tryb awarii 4 to błąd w kodowaniu, a nie problem po stronie backendu, ale efekt wizualny jest taki sam – chwilowe drgnienie interfejsu, które użytkownik może przypisać niestabilności systemu. Zasługuje na miejsce na tej liście, ponieważ jest to pierwsza rzecz, która się psuje, gdy ktoś refaktoryzuje obsługę zdarzeń i przenosi funkcję setOptimistic przed startTransition w imię uporządkowania kodu.

    Tryb awarii 5 – pomyślne wykonanie revalidatePath, ale problem z dwukrotnym kliknięciem

    Ustaw FAIL_MODE na 'race'. Narzędzie testowe wywołuje dwa kliknięcia w odstępie 50 ms od siebie. Oba powodują przejścia stanów, oba optymistycznie przechodzą w stan Paid. Pierwsza operacja zapisu zostaje ukończona i ponownie zweryfikowana; druga operacja zapisu przebiega niezależnie.

    Funkcja setPaid(id, paid) w symulowanym sklepie ustawia wartość bezwzględną zamiast zmieniać wartość logiczną w bazie danych, więc prawdziwy błąd znajduje się w kodzie po stronie klienta:

    setOptimisticPaid(!optimisticPaid);
    await togglePaid(invdsoice.id, !optimisticPaid);
    

    Gdy drugie kliknięcie następuje wystarczająco szybko, wartość przechowywana w zamykanej funkcji przez optimisticPaid (lub invoice.paid) to albo wartość z czasu przed pierwszym kliknięciem, albo wartość odczytana w trakcie procesu z nadal trwającej optymistycznej aktualizacji — wynik zależy od dokładnego momentu. Jedna z dwóch żądań ostatecznie wysyła wartość paid: false.

    Zauważone zjawisko (5/5 przy użyciu przestarzałej logiki przełączania):

    • Pierwszy kliknięcie sprawia, że stan zmienia się na „Paid”
    • Drugi kliknięcie, mniej więcej po 50 ms, wysyła błędną wartość bezwzględną w co najmniej 4 z 5 prób
    • Rozpoczynają się dwie oddzielne fale odświeżania za pomocą revalidatePath
    • Ostateczna wartość z warstwy RSC pokazuje Unpaid, mimo że użytkownik widział, jak najpierw stało się to „Paid” – efekt migotania a następnie zniknięcia
    • W najgorszym przypadku liczba renderowań w tym wierszu osiąga 9: dwa optymistyczne renderowania, dwie zakończone operacje, dwa odświeżenia RSC oraz standardowe renderowania

    Rozwiązaniem jest obliczanie następnej wartości na podstawie ustalonego punktu wyjścia powiązanego z intencją kliknięcia, a nie na podstawie tego, co akurat przechowuje zmienna zamknięta, oraz wyłączenie przełącznika, gdy 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);
    });
    

    Jeśli pominąć tę poprawkę, widok użytkownika typu „ghost” pozostaje nawet po pomyślnym zakończeniu — nic się nie wywołuje, Zod nigdy nie jest uruchamiany, a mimo to interfejs nadal oszukuje użytkownika. Dlatego tryb 5 powinien znaleźć się w kolumnie widoku typu „ghost”, a nie w kolumnie odwracania zmian.

    Tabela wyników

    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.
    

    Twierdzenie umieszczone w podtytule, teraz poparte liczbami: trzy tryby awarii powodują odwrócenie zmian, a dwa pozostawiają widok typu „ghost”. Tryby 1 i 3, plus tryb 2 przy starannym obsłudze, tworzą grupę odwracania zmian. Tryby 2-eager i 5 tworzą grupę typu „ghost”. Tryb 4 to element dodatkowy — nigdy nie utrzymuje optymistycznego nakładki na tyle długo, by można go było jednoznacznie zaliczyć do którejś z kategorii.

    Wersja poprawiona: przechwytywanie błędów, przetrwanie przejścia, opcjonalne użycieActionState

    Odwrócenie stanu już działało, gdy coś wywoływało błąd. Brakowało jednak błędu, który pozostaje aktywny po zakończeniu przejścia, oraz wartości bazowej, która jest aktualizowana tylko wtedy, gdy wynik faktycznie wynosi 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>
      );
    }
    

    Oto, co faktycznie się zmieniło:

    1. setOptimisticPaid jest teraz wywoływany tylko wewnątrz startTransition, co całkowicie eliminuje tryb 4.
    2. next jest obliczany na podstawie invoice.paid, czyli wartości zapisanej, zamiast na podstawie możliwie sprzecznej wartości optymistycznej, co zmniejsza ryzyko związane z trybem 5.
    3. Kontrola jest wyłączana za pomocą disabled={pending}, gdy nakładka i wartość bazowa się różnią, co zapobiega sytuacji podwójnego kliknięcia.
    4. Kes try/catch otacza przypadek wywołania błędu, dzięki czemu tryb 1 teraz wyświetla komunikat po wykonaniu odwrócenia stanu.
  • Gdy res.ok ma wartość false, aktualizuje się tylko stan błędu, podstawa pozostaje nietknięta, więc tryby 2 i 3 są cofane, przy czym nadal wyjaśniana jest przyczyna.
  • Sam błąd jest przechowywany w useState, nigdy nie wewnątrz wartości optymistycznej, więc przetrwa nawet po usunięciu nakładki.
  • To szóste punkt wymagało drugiej analizy, aby go zrozumieć. Jeśli umieści się błąd wewnątrz reduktora optymistycznego, znika on natychmiast po zakończeniu działania – cofnięcie usuwa zarówno twoją wiadomość, jak i przestarzałe interfejsy. Zwykły useState (lub stan zwracany przez useActionState) jest kanałem, który nadal funkcjonuje po zniknięciu nakładki.

    Opcjonalnie: useActionState dla wersji w kształcie formularza

    Jeśli przełącznik jest implementowany jako <form action>, możesz pozwolić, aby useActionState przenosił ostatni wynik podczas przejścia, zamiast ręcznie zarządzać tym stanem:

    '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>
      );
    }
    

    Zasady się nie zmieniają. Funkcja ustawiająca nadal jest wykonywana wewnątrz akcji. Wartość bazowa nadal jest aktualizowana tylko po pomyślnej ponownej weryfikacji. Najnowszy błąd nadal pozostaje w state, gdy nakładka znika. Użyj tego wzorca, gdy kontrola jest naturalnie formularzem; wersję z przyciskiem i useTransition zachowaj dla kompaktowych wierszy tabeli.

    Oceny po zastosowaniu poprawki

    Ty same pięć trybów awarii, po pięć prób każdy, zostało ponownie przetestowanych na poprawionym wierszu.

    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.
    

    W przypadku pomyślnego przebiegu wyświetla się średnio 3 razy — montowanie, optymistyczne narysowanie, a następnie synchronizacja RSC. Gdy błąd zawiera komunikat, liczba wyświetleń wzrasta do 4 — montowanie, optymistyczne narysowanie, cofnięcie zmian, a potem narysowanie komunikatu o błędzie. To czwarte wyświetlenie stanowi koszt, który warto zapłacić za taką wiersz tabeli.

    Z pięciu wywołanych błędów trzy cofają się automatycznie. Dwa nadal pozostawiają „upiorne” elementy interfejsu: błąd łagodny, który szybko aktualizuje bazę danych, oraz sytuacja z podwójnym wywołaniem.

    Ślad wyświetleń z trybu 1

    Oto surowe dane uzyskane za pomocą funkcji performance.now() z próby 3 trybu 1, przy wyłączonym Strict Mode i zmontowanym jednym wierszem.

    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
    

    Gdy uruchomimy ten sam test na uszkodzonej wersji tutorialu, proces zatrzymuje się przy renders=3, bez żadnego komunikatu ostrzegawczego. To właśnie ta czwarta operacja rysowania stanowi całą różnicę pomiędzy komponentem użytecznym a uszkodzonym. Sam rollback nigdy nie był trudny — trudno było utrzymać kanał stanu aktywny po zniknięciu optymistycznego nadpisu.

    Czwarta operacja rysowania ma większe znaczenie niż skracanie czasu wykonywania operacji o kilka milisekund. Użytkownik zaakceptuje błędny etykietę przez 400 ms, jeśli interfejs wyjaśni mu powód. Nie zaakceptuje natomiast etykiety, która kłamie w sposób pewny, a potem cicho się poprawia później, niezauważalnie, dopóki ktoś o tym nie zapyta podczas spotkania.

    Wnioski do następnego pull requesta

    useOptimistic dostarcza tymczasową warstwę nadpowierzchniową, która istnieje tylko do chwili zakończenia przejścia. Jeśli operacja wywoła błąd i wartość bazowa nie została zaktualizowana, React przywraca stan interfejsu. Delikatna odpowiedź { ok: false } bez aktualizacji wartości bazowej powoduje takie samo przywrócenie. Oba zachowania są zgodne z tym, co podaje dokumentacja, i zostały potwierdzone tutaj poprzez bezpośrednie testy.

    Tego, czego dokumentacja nie dostarcza automatycznie:

    • Czytelne komunikat dla użytkownika po przywróceniu stanu
    • Ochronę przed delikatnym { ok: false }, jeśli mimo wszystko zaktualizujesz wartość bazową
    • Ochronę przed wywoływaniem funkcji ustawiającej poza granicami przejścia
    • Idempotentne przełączanie przy szybkich podwójnych kliknięciach w połączeniu z revalidatePath

    Automaticzne cofanie działa zgodnie z obietnicami. Dobry obsługa błędów nie jest darmowa. Trzy z pięciu celowo uszkodzonych przypadków wróciły do poprzedniego stanu same, natomiast pozostałe dwa nadal wyświetlały przestarzałe interfejsy, dopóki zwrot „akcja zakończona” nie przestał być traktowany jako synonim „akcja zakończona pomyślnie”.

    Krótki lista kontrolna, którą warto wkleić podczas przeglądania kodu:

    1. Czy setOptimistic jest wykonywany wewnątrz startTransition, czy też przez właściwość action formularza?
    2. Czy baza danych (lub jej lokalna kopia) jest aktualizowana tylko po potwierdzeniu res.ok, czy też po pomyślnym zakończeniu bez rzucania błędów, które również powoduje ponowną weryfikację?
    3. Czy błąd znajduje się w useState czy w useActionState, oddzielnie od reduktora optymistycznego?
    4. Czy następna wartość jest obliczana na podstawie bazy danych serwera, przy czym kontrola jest wyłączona, dopóki akcja nie zostanie zakończona?
  • Czy ktoś faktycznie wypróbował zarówno ścieżkę rzucania, jak i ścieżkę { ok: false } w przeglądarce, a nie tylko przełączanie się między „szczęśliwymi” scenariuszami?
  • Jeśli przykład w instrukcji kończy się na wywołaniu setOptimistic i oczekiwaniu na działanie, to jest to wersja z cichym niepowodzeniem. Złap błąd rzucenia. Przyjrzyj się wynikowi. Przechowuj błędy w useState lub useActionState. Wyłącz kontrolę, gdy nakładka i serwer są w rozbieżności. Dzięki temu mechanizm cofania, który React już oferuje za darmo, staje się czymś, z czym rzeczywisty użytkownik może sobie poradzić.

    Literatura pokrewna

  • Dlaczego Next.js Server Actions wymagają autoryzacji wewnątrz każdego ciała funkcji — Przykład przejęcia konta pokazuje, w jaki sposób nieautoryzowane Next.js Server Actions umożliwiają wykonywanie uprzywilejowanych operacji, oraz gdzie musi znajdować się sprawdzanie autoryzacji, aby temu zapobiec.
  • Pięć zaщит-bezpieczeństwa frontendu, których potrzebuje każda aplikacja React i Next.js — Dlaczego aplikacje React i Next.js w produkcji używają plików cookie typu HttpOnly, CSP, DOMPurify, nagłówków bezpieczeństwa oraz reguł NEXT_PUBLIC_, oraz jakie ataki każda z tych metod blokuje.