useOptimistic Rollback: Fünf Fehlermodi bei Next.js Server Actions
Erfahren Sie, warum useOptimistic die Benutzeroberfläche stumm zurücksetzt, ohne den Nutzern Fehlerrätsel zu erklären – anhand von fünf getesteten Fehlermodi bei Serveraktionen sowie einer funktionierenden Lösung.
Der automatische Rollback funktioniert genau wie versprochen. Die Anzeige der Fehlermeldung vor dem Benutzer hingegen nicht. Ich habe absichtlich fünf verschiedene Fehlerverläufe bei einem Server Action-Schalter in Next.js ausgelöst und aufgezeichnet, was tatsächlich auf dem Bildschirm angezeigt wurde.
Ein Rollback kostet nichts. Die Kommunikation des Fehlers hingegen schon. Der bezahlte Zustand wechselt und kehrt anschließend stumm zurück – ohne dass der Benutzer eine Erklärung lesen kann.
Die meisten Tutorials behandeln useOptimistic als eine kostenlose Löschtaste. Man klickt darauf, die Benutzeroberfläche wechselt, der Anfrage wird ein Fehler gemeldet und die Oberfläche kehrt wieder in ihren ursprünglichen Zustand zurück. Mehr nicht.
Ich wollte überprüfen, ob dieses Versprechen auch dann noch gilt, wenn die Dinge unübersichtlich werden. Deshalb habe ich in Next.js App Router eine kleine Rechnungsliste erstellt – fünf Zeilen, jede mit einer eigenen Server Action verknüpft – und in echtem Code fünf verschiedene Möglichkeiten erzeugt, wie diese Action fehlschlagen kann. Keine künstlich geschaffenen Randfälle, sondern die Fehler, die unbemerkt in die Produktion gelangen.
In insgesamt fünf Ausführungen pro Fehlermodus rollten drei davon die Benutzeroberfläche korrekt zurück. Zwei ließen die Oberfläche weiterhin falsche Informationen anzeigen. Die automatische Rücksetzung funktioniert, wenn die Action einen Fehler auslöst. Sie funktioniert jedoch nicht, wenn die Action stillschweigend etwas wie { ok: false } zurückgibt anstelle eines Fehlers auszulösen – dieses Verhalten ist eine subtile Art, den Benutzer unbeabsichtigt in die Irre zu führen.
Zur Referenz: Dies wurde unter Verwendung von Version 15.5.2 von Next.js entwickelt, in Kombination mit React 19.1.1, kompiliert mit TypeScript 5.9.2 und läuft unter Node.js 22.x auf einem Linux-System. Die folgenden Zahlen stammen aus genau dieser Umgebung. Wenn Sie denselben Test auf einem anderen Rechner ausführen, kann sich die Zeitangabe leicht ändern, doch das Muster jeder Fehlermeldung sollte unverändert bleiben.
Was die Dokumentation tatsächlich verspricht
Die offizielle Referenzseite für useOptimistic macht eine klare Aussage: Der optimistische Wert wird nur angezeigt, solange eine Aktion noch läuft; sobald diese abgeschlossen ist, wechselt React zurück zu der Anzeige des tatsächlichen Werts, den er aktuell enthält.
Dies erläutert auch, was passiert, wenn etwas schiefgeht. Kurz gesagt: Ein unerfasster Fehler innerhalb der Action lässt die ausstehende Transition dennoch normal abgeschlossen werden. Da der umgebende Code in der Regel erst nach einem erfolgreichen Aufruf auf den tatsächlichen value schreibt, bedeutet ein Fehler, dass dieser Wert nie übertragen wurde – daher zeigt React nach Abschluss der Transition einfach wieder dieselbe Benutzeroberfläche an, die der Nutzer vor dem Klick gesehen hat. In den Dokumentationen wird darauf hingewiesen, dass man selbst diesen Fehler fangen muss, wenn man dem Nutzer eine Nachricht anzeigen möchte; React übernimmt das nicht für einen.
Zwei weitere Details sind hier wichtig:
- Der optimistische Setter muss innerhalb einer Action oder innerhalb von
startTransitionausgeführt werden. Wenn er außerhalb davon läuft, gibt React eine Warnung aus, und die optimistische Benutzeroberfläche erscheint nur kurz, bevor sie wieder verschwindet.
Dieser zweite Punkt ist im Grunde die Kernidee dieses Ganzen. Die Rücksetzung der Benutzeroberfläche erfolgt automatisch. Ob Sie dem Benutzer den Grund mitteilen, liegt bei Ihnen. Der Hook zeigt einen vorhergesagten Wert solange an, wie eine Aktion aussteht, und stimmt diesen anschließend mit dem tatsächlichen Wert des Elternelements ab. Wenn Sie etwas ausführen, ohne diesen Basiswert zu ändern, kommt es zu einer Rücksetzung. Wenn die Aktion erfolgreich abgeschlossen wird, ohne den Wert zu ändern, kommt es ebenfalls zu einer Rücksetzung. Wenn die Aktion erfolgreich abgeschlossen wird, aber der Basiswert mit dem falschen Ergebnis aktualisiert wird, entsteht ein „Geisterzustand“ – eine Benutzeroberfläche, die etwas anzeigt, was auf dem Server tatsächlich nie stattgefunden hat.
Mini-App: Schalter für bezahlte Rechnung
Anstatt eines einfachen Beispieldemos ahmt die Test-App ein Rechnungsdisplay nach, denn genau dort verwandelt sich ein falsches „Gebucht“-Label in einen Anruf der Forderungsabteilung.
So ist die Struktur aufgebaut:
- Eine RSC-Seite lädt fünf Rechnungen aus einem im Speicher gespeicherten Datensatz (
INV-1001bisINV-1005, alle zu Beginn unbezahlt). - Jede Zeile wird als eigenständige Client-Komponente dargestellt, die einen optimistischen Booleschen Wert enthält.
- Durch Klicken auf den Schalter wird eine Server-Aktion mit einem expliziten Booleschen Wert für „bezahlt“ aufgerufen.
revalidatePath('/invoices')wird nur ausgeführt, wenn die Aktion erfolgreich ist.- Jede Zeile verfolgt einen Render-Zähler, der bei jeder Neuzeichnung erhöht wird. Der strenge Modus ist deaktiviert, damit dieser Zähler durch doppelte Aufrufe nicht überhöht wird.
await sleep(400), damit das optimistische Zeitfenster lang genug ist, um visuell beobachtet und mit performance.now() gemessen werden zu können.// 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>
);
}
Die Regel für jeden Testlauf: Setzen Sie den Rechnungsdatenspeicher auf „unbezahlt“ zurück, aktivieren Sie FAIL_MODE, klicken Sie einmal auf den Schalter (zweimal für Modus fünf), warten Sie 800 ms, nachdem die Aktion abgeschlossen ist, prüfen Sie anschließend den Beschriftungstext der Schaltfläche, überprüfen Sie data-renders, notieren Sie alle Konsole-Warnungen und erstellen Sie ein Screenshot. Jeder Modus wurde fünfmal ausgeführt, stets mit derselben Rechnungs-ID, ohne Einsatz von React Query oder einer externen Caching-Schicht – nur RSC-Props, useOptimistic und eine einzige Server Action.
Die Komponente für den „happy path“ sieht aus wie etwas aus jedem Einführungs-Tutorial:
// 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>
);
}
Und hier ist die Server Action selbst, mit einem Fehler-Schalter, den das Testframework nach Bedarf aktivieren kann:
// 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 };
}
Fehlermodus 1 – Server Action wirft Ausnahmen aus
FAIL_MODE = 'throw'. Die Action wirft nach einer Verzögerung von 400 ms eine Ausnahme aus. Am Client wird diese nicht abgefangen. Dies ist genau das Szenario, das in der Dokumentation beschrieben wird.
Erwartet: Die Übertragung wird abgeschlossen, der zugrunde liegende Wert von invoice.paid ändert sich nicht, die optimistische Schicht verschwindet und der Button zeigt wieder „Unpaid“ an.
Beobachtet (5 von 5 Ausführungen):
- t=0ms: Klick wird registriert, das Etikett wechselt sofort auf „Paid“ (die optimistische Anzeige)
- t≈400ms: Die Ausnahme tritt auf und beendet die Übertragung
- t≈410ms: Das Etikett wechselt zurück auf „Unpaid“
Rollback-Verhalten: läuft wie dokumentiert. Fehlerbehandlung: fehlerhaft. Aus Sicht des Benutzers zeigte die Rechnung etwa 400 Millisekunden lang „Gebucht“ an und kehrte anschließend ohne Erklärung wieder auf „Nicht bezahlt“ zurück. Technisch entspricht dies dem Konzept des „automatischen Rollbacks“. In der Praxis ist das in einem echten Produkt jedoch nicht nutzbar. Wenn man aus der Dokumentation nur das Prinzip „Es kommt bei Fehlern zum Rollback“ mitgenommen hat, ist das genau das Ergebnis, das man am Ende liefern würde.
Auch habe ich überwacht, ob der Elternserver-Komponente neu gerendert wurde. Das geschah nicht – invoice.paid veränderte sich nie. Die Rückkehr zum ursprünglichen Zustand erfolgte ausschließlich, weil die optimistische Überlagerung verschwand, und nicht, weil eine inverse Aktualisierung ausgeführt wurde. Es gab nirgends auf der Client-Seite einen Aufruf von setPaid(false). Die Basis-Eigenschaft blieb genau dort, wo sie begonnen hatte; daher zeigte die Benutzeroberfläche, sobald die optimistische Schicht weg war, einfach wieder den ursprünglichen Wert an. Das ist das gesamte Mechanismus, und er spielt eine Rolle, wenn wir zum unten beschriebenen Fall eines sanften Fehlers kommen.
Fehlermodus 2 – Sanfter { ok: false }, kein Auswurf
Hier geraten Teams häufig in Schwierigkeiten. Anstatt einen Fehler auszulösen, geben viele Implementierungen ein strukturiertes Ergebnis zurück, damit der Fehlerpfad eine korrekte Typisierung hat. Eine vernünftige Wahl – es sei denn, der Client-Code prüft diesen Rückgabewert nie; in diesem Fall erfolgt die Übertragung dennoch erfolgreich aus Sicht von React.
// still the happy-tutorial handler
startTransition(async () => {
setOptimisticPaid(!optimisticPaid);
await togglePaid(invoice.id, !optimisticPaid); // returns { ok: false }
});
FAIL_MODE = 'soft'. Der zugrunde liegende Store bleibt unverändert. revalidatePath wird niemals ausgelöst. Die Aktion wird mit { ok: false, code: 'BIZ', message: 'Invoice locked' } abgeschlossen – es wird keine Ausnahme ausgelöst.
Was man erwarten würde, wenn man auf „automatische Rücksetzung bei Fehler“ setzt: Die Benutzeroberfläche wechselt zurück, weil die Mutation nicht erfolgreich war.
Beobachtet mit dem oben genannten simplen Handler (5/5 Ausführungen):
- Der optimistische Zustand wechselt auf „Paid“
falseres niemals gelesen wurdeAuch die „ordnungsgemäß funktionierende“ Version kehrt somit korrekt in den ursprünglichen Zustand zurück. Ein fehlgeschlagener Vorgang veranlasst nicht dazu, dass der optimistische Wert weiterhin bestehen bleibt – die Überlagerung verschwindet immer, wenn die Aktion abgeschlossen ist, unabhängig davon, ob ein Fehler auftrat. Der Schwerpunkt in der Dokumentation auf Fehlern beschreibt den typischen Fall, nicht den einzigen. Jede Aktion, die ohne Veränderung des Basiszustands abgeschlossen wird, kehrt in den ursprünglichen Zustand zurück.
Woher kommt also diese „geisterhafte“ Benutzeroberfläche eigentlich?
Dies tritt auf, wenn der Handler versucht, „klug“ zu sein, indem er den lokalen Basenzustand jedes Mal aktualisiert, wenn die Promise abgeschlossen wird – zum Beispiel, wenn Sie das Zahlungsflag in einen useState spiegeln und es vor der Überprüfung von ok setzen:
// 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
});
Mit diesem Muster in Kraft (5/5 Ausführungen):
- Die Schaltfläche bleibt auch nach dem Fehler auf Zahlt stehen
- Eine Fehlermeldung könnte unter der Zeile angezeigt werden
- Der zugrunde liegende RSC-Store enthält weiterhin „Nicht gezahlt“
- Die nächste Navigation oder jede spätere Neuerkennung setzt die Zeile wieder zurück – wodurch ein Geisterzustand entsteht, der bestehen bleibt, bis etwas einen Neuladen erzwingt
Zum Zusammenfassung der Bewertung: Ein sanfter Fehler ohne Aktualisierung der lokalen Datenbank bedeutet, dass ein Rollback funktioniert, es aber keine Benutzerfeedbacks gibt. Ein sanfter Fehler in Kombination mit einer sofortigen Aktualisierung der lokalen Datenbank führt zu einem Ghost UI. Genau dieser zweite Fall erscheint später im Leistungsbericht als Fehlermodus 2. Der eigentliche Defekt liegt nicht in der Form { ok: false } selbst – sondern darin, dass „die Aktion ist abgeschlossen“ als gleichbedeutend mit „die Aktion war erfolgreich“ behandelt wird.
Fehlermodus 3 – Zod-Validierung, strukturiertes Fehlermeldung, keine Ausnahme
FAIL_MODE = 'zod'. Strukturell entspricht dies Modus 2, wird nur auf andere Weise ausgelöst. Ein Aufruf von safeParse fehlschlägt (oder der Fehlerweg wird erzwungen), und die Aktion gibt { ok: false, code: 'VALIDATION', message: 'Invalid toggle payload' } zurück. Es wird keine Ausnahme geworfen, und revalidatePath wird übersprungen.
Dieser Fall verdient einen eigenen Abschnitt, denn Teams neigen dazu, Validierungsfehler als grundsätzlich „sicher“ zu betrachten – sie sind vorhersehbar, typisiert und werden absichtlich behandelt. Die Nutzer erkennen diese Nuancen nicht. Aus ihrer Sicht flackert der Schalter nur kurz auf und bleibt anschließend still.
Bemerkt (5/5 Ausführungen) mit einer Anwendung, die den Basenzustand nur dann aktualisiert, wenn ok wahr ist:
- Der Zustand „Optimistic Paid“ erscheint, die Übertragung endet, danach kehrt der Zustand wieder zu „Unpaid“ zurück
- Durchschnittliche Anzahl der Darstellungen: 4
- Dauer, während die Beschriftung „Paid“ sichtbar war: etwa 400–420 ms
- Nachricht für den Benutzer: keine, es sei denn, der Code weicht explizit je nach
resab
Validierungsfehler mögen vertrauenswürdiger erscheinen, da TypeScript ihre Struktur vorgibt, doch das bedeutet nicht, dass die Benutzeroberfläche dadurch besser wird – im Gegenteil, es ist nur leiser. Das Zurücksetzen verhält sich genauso, und die Stille bleibt unverändert. Hätte das Formular useActionState verwendet und den zurückgegebenen Wert in seinen state abgebildet, hätte die Meldung den Übergang überlebt. Der einfache, tutoriell gestaltete Zeilenkomponente tut das nicht.
Eine Klarstellung, die erwähnenswert ist: Die Durchführung der Zod-Validierung auf der Client-Seite *vor* dem Aufruf von setOptimistic würde verhindern, dass der Paid-Zustand jemals angezeigt wird. Modus 3 bezieht sich konkret auf Serverseitige Validierungen, die *nach* dem bereits erfolgten optimistischen Anzeigen fehlschlagen. Genau diese Reihenfolge verursacht das Problem.
Fehlermodus 4 – Aufruf von addOptimistic außerhalb von startTransition
function onToggle() {
// 🚩 outside a Transition
setOptimisticPaid(!optimisticPaid);
startTransition(async () => {
await togglePaid(invoice.id, !optimisticPaid);
});
}
In der Dokumentation wird genau vor dieser Situation gewarnt: Wenn man den optimistischen Zustand aktualisiert, ohne ihn in eine Transition oder eine Action einzubetten, erscheint die Änderung für einen Moment und kehrt dann fast sofort zu ihrem ursprünglichen Wert zurück, da es keinen Übergangsbereich gibt, der sie während des Abschlusses der zugrunde liegenden Aufgabe aufrecht erhält.
Fehlt eine Transition, die den Aufruf umschließt, gibt es nichts, was die Vorhersage während des Ausführens der asynchronen Aufgabe am Leben erhält. React verfügt über keinen Bereich, an den der optimistische Wert angehängt werden könnte, weshalb er einfach wieder zurückgesetzt wird.
Beobachtet (5/5):
- Ein schneller Wechsel zu „Paid“, meist nur ein einzelner Frame, gelegentlich zwei Zeichnungen
- Eine sofortige Rückkehr zu „Unpaid“, noch bevor die 400-Millisekunden-Aktion abgeschlossen ist
- Eine Warnung in DevTools von React bei jedem Klick
revalidatePath abgeschlossen ist eine weitere Änderung, sobald frische Serverdaten ankommen, wodurch der Benutzer eine kurzzeitige Reaktion bemerkt und anschließend eine verzögerte Speicherung erfolgtEs handelt sich hier nicht um einen durch einen Fehler ausgelösten Rollback. Besser beschrieben ist es als „wurde nie tatsächlich beibehalten“. Fehlermodus 4 ist ein Programmierfehler und kein Backend-Problem, doch das visuelle Ergebnis ist dieselbe kurzzeitige Reaktion, die der Benutzer einer Instabilität zuschreibt. Er findet sich auf dieser Liste, weil er das Erste ist, was kaputtgeht, wenn jemand einen Handler umstrukturiert und setOptimistic aus Gründen der Klarheit vor startTransition platziert.
Fehlermodus 5 – erfolgreicher revalidatePath, aber Doppelklick-Wettlauf
Setzen Sie FAIL_MODE auf 'race'. Das Testframework sendet zwei Klicks innerhalb von 50 ms nacheinander ab. Beide auslösen Übergänge und springen optimistisch auf den Zustand „Paid“. Der erste Schreibvorgang wird abgeschlossen und erneut validiert; der zweite Schreibvorgang erfolgt unabhängig weiter.
Die Methode setPaid(id, paid) des Mock-Ladens setzt einen absoluten Wert statt einen booleschen Wert in der Datenbank zu ändern, wodurch das eigentliche Problem auf der Client-Seite liegt:
setOptimisticPaid(!optimisticPaid);
await togglePaid(invdsoice.id, !optimisticPaid);
Wenn der zweite Klick schnell genug erfolgt, ist der Wert von optimisticPaid (oder invoice.paid) innerhalb dieser Schleife entweder der Wert vor dem ersten Klick oder ein Wert, der während des Vorgangs aus der noch ausstehenden optimistischen Aktualisierung gelesen wird – das Ergebnis hängt vom genauen Zeitpunkt ab. Einer der beiden Anfragen sendet schließlich paid: false.
Bemerktes Verhalten (5/5 mit der veralteten Schalterlogik):
- Der erste Klick macht es zu „Paid“
- Beim zweiten Klick, etwa 50 ms später, wird in mindestens 4 von 5 Versuchen der falsche absolute Wert übermittelt
- Zwei separate Aufrufe von
revalidatePathfinden statt - Der endgültige Wert aus der RSC-Schicht lautet Unpaid, obwohl der Benutzer gesehen hat, wie es zuerst zu „Paid“ wurde – ein Phänomen von kurzzeitiger Anzeige und anschließendem „Geisterbild“
- In dem schlimmsten Fall beträgt die Anzahl der Renderungen pro Zeile 9: zwei optimistische Darstellungen, zwei abgeschlossene Aktionen, zwei RSC-Aufrufe sowie die Basis-Renderungen
Die Lösung besteht darin, den nächsten Wert von einem festen Ausgangspunkt abzuleiten, der mit der Absicht des Klicks verbunden ist und nicht von dem Wert abhängt, den die Schließfunktion zufällig enthält, sowie den Schalter zu deaktivieren, solange optimisticPaid !== invoice.paid gilt.
const next = !invoice.paid; // from base, not from a racing optimistic read
startTransition(async () => {
setOptimisticPaid(next);
const res = await togglePaid(invoice.id, next);
});
Wenn man diese Korrektur überspringt, bleibt die „Geist-UI“ auch auf dem Erfolgsweg bestehen – es wird nichts ausgelöst, Zod läuft nie, und dennoch täuscht die Benutzeroberfläche den Nutzer weiterhin. Deshalb gehört Modus 5 in die Spalte der „Geist-UI“ und nicht in die Spalte des Rollbacks.
Scoreboard
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.
Die Aussage des Untertitels, nun durch Zahlen untermauert: Drei Fehlermodi führen zu einem Rollback, während zwei eine „Geist-UI“ hinterlassen. Modus 1 und 3 sowie Modus 2, wenn sorgfältig behandelt, bilden die Gruppe für den Rollback. Modus 2-eager und Modus 5 bilden die Gruppe der „Geist-UI“. Modus 4 ist die zusätzliche Komponente – er hält die optimistische Überlagerung nie lange genug aufrecht, um eindeutig einer der Kategorien zugeordnet zu werden.
Korrigierte Version: Fehler abfangen, Übergang überstehen, optional useActionState verwenden
Der Rollback funktionierte bereits, sobald etwas fehlschlug. Was fehlte, war ein Fehler, der über das Ende der Übertragung hinaus bestehen bleibt, sowie ein Basiswert, der nur dann voranschreitet, wenn das Ergebnis tatsächlich ok: true ist.
// 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>
);
}
Hier ist, was tatsächlich geändert wurde:
setOptimisticPaidwird nun nur innerhalb vonstartTransitionausgelöst, wodurch Modus 4 vollständig entfernt wird.nextwird ausinvoice.paid, dem gespeicherten Wert, berechnet, anstatt einen möglicherweise konkurrierenden optimistischen Wert zu lesen, was die Wirkung von Modus 5 verringert.- Die Steuerung wird mit
disabled={pending}deaktiviert, sobald sich das Overlay und der Basiswert unterscheiden, wodurch ein Doppelklick-Konflikt verhindert wird. - Ein
try/catchumschließt den Fehlerfall, sodass Modus 1 nun nach Ausführung des Rollbacks eine Meldung anzeigt.
res.ok falsch ist, wird nur der Fehlerzustand aktualisiert, der Basiszustand bleibt unverändert, wodurch die Modi 2 und 3 rückgängig gemacht werden, während gleichzeitig erklärt wird, warum.useState gespeichert, niemals innerhalb des optimistischen Wertes, sodass er auch nachdem das Überlagerungsfenster entfernt wurde weiterhin vorhanden ist.Diesen sechsten Punkt musste ich mir erst einmal genauer durch den Kopf gehen lassen. Wenn man den Fehler in den optimistischen Reducer legt, verschwindet er sofort, sobald die Aktion abgeschlossen ist – bei der Rücksetzung werden dann auch deine eigenen Nachrichten zusammen mit dem veralteten UI entfernt. Ein einfaches useState (oder der von useActionState zurückgegebene Zustand) ist der Kanal, der auch nach dem Verschwinden des Überlagerungsfensters weiterhin bestehen bleibt.
Optional: useActionState für die formbasierte Version
Falls der Schalter als <form action> implementiert wird, können Sie useActionState dazu nutzen, das letzte Ergebnis während des Übergangs weiterzugeben, anstatt diesen Zustand manuell zu verwalten:
'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>
);
}
Die Regeln ändern sich nicht. Der Setter wird weiterhin innerhalb der Action ausgeführt. Der Basiswert wird weiterhin nur nach einer erfolgreichen Neuerstellung aktualisiert. Der neueste Fehler bleibt weiterhin in state gespeichert, sobald das Überlagerungsfenster verschwindet. Wählen Sie dieses Muster, wenn der Steuerelement natürlicherweise ein Formular ist; verwenden Sie die Version mit Button plus useTransition für kompakte Tabellenspalten.
Werte nach Anwendung der Korrektur
Gegen die korrigierte Zeile wurden erneut die gleichen fünf Fehlermuster, jeweils fünfmal, getestet.
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.
Bei einem reibungslosen Erfolgsverlauf werden durchschnittlich 3 Aufladungen der Zeile ausgeführt – Aufladen, optimistische Darstellung, anschließend Abgleich durch RSC. Wenn ein Fehler eine Meldung mit sich bringt, steigt die Anzahl auf 4 – Aufladen, optimistische Darstellung, Rollback und danach Fehlerdarstellung. Diese vierte Aufladung ist der Preis, den man für eine solche Tabellenzeile zahlen muss.
Von den fünf erzeugten Fehlern werden drei automatisch rückgängig gemacht. Zwei hinterlassen jedoch weiterhin sichtbare UI-Elemente: der sanfte Fehler, der die Basisdaten sofort aktualisiert, sowie das Problem durch gleichzeitige Ausführungen.
Aufladungsspuren aus Modus 1
Hier sind die rohen performance.now()-Werte aus dem dritten Test von Modus 1 aufgeführt, wobei der strenge Modus deaktiviert war und nur eine Zeile geladen wurde.
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
Wenn man denselben Test an der fehlerhaften Tutorial-Version durchführt, kommt sie bei renders=3 zum Stillstand, ohne dass überhaupt eine Meldung angezeigt wird. Genau diese vierte Darstellung macht den Unterschied zwischen einem nutzbaren Komponenten und einem defekten aus. Der Rückrollvorgang an sich war nie das Schwierige – schwierig war es, den State-Channel am Leben zu halten, nachdem die optimistische Überlagerung verschwunden war.
Diese vierte Darstellung ist wichtiger als das Weglassen einiger Millisekunden auf dem optimistischen Pfad. Ein Benutzer akzeptiert es, wenn ein Label 400 Millisekunden lang falsch angezeigt wird, solange die Benutzeroberfläche ihm erklärt, warum. Er wird jedoch nicht akzeptieren, dass ein Label zuversichtlich falsche Angaben macht und sich anschließend unbemerkt von selbst korrigiert, bis jemand im Standup danach fragt.
Fazit für den nächsten Pull Request
useOptimistic stellt Ihnen eine vorübergehende Überlage zur Verfügung, die nur so lange besteht, bis die Übergangsphase abgeschlossen ist. Wenn die Aktion einen Fehler wirft und der Basiszustand nie aktualisiert wird, kehrt React die Benutzeroberfläche in ihren ursprünglichen Zustand zurück. Eine sanfte { ok: false }-Antwort ohne Aktualisierung des Basiszustands führt ebenfalls zu dieser Rückkehr. Beide Verhaltensweisen entsprechen den Angaben in der Dokumentation und wurden hier durch direkte Tests bestätigt.
Was die Dokumentation Ihnen nicht automatisch bietet:
- Eine verständliche Meldung für den Benutzer, sobald der Zustand wiederhergestellt wird
- Schutz vor einer sanften
{ ok: false }-Reaktion, falls Sie den Basiszustand dennoch aktualisieren - Schutz davor, den Setter außerhalb der Übergangsgrenzen aufzurufen
- Idempotentes Schalten bei schnellen Doppelklicks in Kombination mit
revalidatePath
Automatic rollback works as advertised. Good error handling does not come free. Three of the five deliberately broken cases snapped back on their own; the other two kept showing stale UI until "the action finished" stopped being treated as synonymous with "the action succeeded."
A short checklist worth pasting into code review:
- Does
setOptimisticrun insidestartTransition, or through a form'sactionprop? - Is base (or its local mirror) only updated once
res.okis confirmed, or after a non-throwing success that also triggers revalidation? - Does the error live in
useStateor inuseActionState, separate from the optimistic reducer? - Is the next value computed from the server's base state, with the control disabled while the action is pending?
- Gibt es jemanden, der tatsächlich sowohl den Wurfpfad als auch den
{ ok: false }-Pfad in einem Browser ausprobiert hat – und nicht nur den „happy-path“-Umkehrschluss?
Falls das Beispiel in einem Tutorial bei der Aufrufung von setOptimistic und dem Warten auf die Aktion endet, wird dabei die Version mit stillschweigendem Fehler bereitgestellt. Fangen Sie den Ausnahmewurf ab, prüfen Sie das Ergebnis und speichern Sie Fehler in useState oder useActionState. Deaktivieren Sie die Steuerung immer dann, wenn sich das Overlay und der Server nicht einigen. Wenn man das tut, wird die bereits von React bereitgestellte Rollback-Funktion zu etwas, womit ein echter Benutzer tatsächlich umgehen kann.
Verwandte Literatur
- Debugging von Next.js Server Actions: Deploy-Fehler, CORS und Payload-Limits — Ein praktischer Fehlerbehebungsführer, der erklärt, warum Next.js Server Actions und API-Endpunkte stillschweigend versagen oder rätselhafte Fehler auslösen, sowie konkrete Lösungen für jeden Fall.
- Frontend im Jahr 2027: Server-First Rendering, TypeScript und Edge-Standarde — Eine detaillierte Betrachtung davon, wie serverbasierte Frameworks, verpflichtendes TypeScript, kI-gestütztes Programmieren sowie Edge-Rendering die Praktiken der Frontend-Entwicklung verändern.