Optimistic UI in React: die Oberfläche, die bewusst lügt
Optimistic UI in React: die Oberfläche, die bewusst lügt
Im Artikel über Mikrointeraktionen habe ich über die Doherty-Schwelle geschrieben – antwortet ein System in weniger als 400 ms, nimmt der Nutzer es als sofort wahr. Mikrointeraktionen schließen diese Lücke, indem sie den Button-Zustand animieren, bevor die Server-Antwort zurückkommt. Optimistic UI geht einen Schritt weiter: Es animiert nicht nur das Warten, sondern nimmt von vornherein an, dass die Aktion gelingen wird, und zeigt sofort das Endergebnis – das Herz im „Gefällt mir"-Button füllt sich sofort, der Zähler steigt um eins, obwohl der Request zum Server gerade erst gestartet ist. Die Oberfläche sagt dem Nutzer etwas, das noch nicht wahr ist – und geht davon aus, dass es gleich wahr sein wird.
Das funktioniert hervorragend, solange die Annahme zutrifft – und in der Praxis trifft sie für Aktionen wie „liken", „zu Favoriten hinzufügen" oder „als gelesen markieren" in der überwiegenden Mehrheit der Fälle zu. Das Problem beginnt dort, wo die meisten Umsetzungen versuchen, das manuell zu machen: setState beim Klick, catch mit umgekehrtem setState bei Fehler. Sieht in einem isolierten Test mit einem einzigen Klick harmlos aus. Es zerfällt, sobald ein Nutzer den Button zweimal schnell hintereinander klickt – genau dasselbe vierte Element aus Saffers Modell (Schleifen und Modi) aus dem Artikel über Mikrointeraktionen, nur dass der Fehler diesmal nicht kosmetisch ist, sondern zu einem tatsächlich inkonsistenten Zustand führt.
Warum ein manueller Rollback bei zwei Klicks aus dem Ruder läuft
Stell dir die naheliegendste, manuelle Implementierung vor: Ein Klick schaltet sofort liked im lokalen State um, sendet einen Request, und im catch wird liked auf den Wert vor dem Klick zurückgesetzt, der in einer Variable vor dem Absenden des Requests gemerkt wurde.
jsx
// Naiver Rollback – funktioniert bei einem Klick, versagt bei zweienconst handleClick = async () => {const previousLiked = liked; // eingefrorener Schnappschuss des Zustands VOR dem KlicksetLiked(!liked);try {await toggleLike(postId, !liked);} catch {setLiked(previousLiked); // setzt auf den Zustand vor DIESEM Klick zurück}};
Das Szenario, das dies zum Einsturz bringt: Der Nutzer klickt „liken" (Start von Request A, previousLiked = false), nach 100 ms klickt er „unliken" (Start von Request B, previousLiked = true, weil sich der lokale State bereits auf true geändert hat). Schlägt Request A fehl, während Request B noch läuft oder bereits erfolgreich war, setzt der catch von Request A den Zustand auf false zurück – und überschreibt damit den Effekt von Klick B, den der Nutzer bewusst ausgeführt hat und der möglicherweise bereits erfolgreich war. Der Rollback eines Requests hat den Effekt des anderen gelöscht, weil beide dieselbe, gemeinsam genutzte State-Variable bearbeitet haben, ohne irgendein Wissen voneinander. Das ist genau die Art von Fehler, die in einem schnellen manuellen Test nicht auffällt – sie zeigt sich erst in Produktion, wenn ein echter Nutzer schneller klickt, als der Entwickler beim Schreiben des Codes vorgesehen hat.
Wie useOptimistic dieses Problem strukturell vermeidet, nicht als Flickwerk
useOptimistic in React ist keine bequemere Verpackung für setState plus try/catch. Es ist ein anderes konzeptionelles Modell: Statt ein einziges, veränderliches State-Feld zu halten, das du manuell umschaltest und manuell zurücksetzt, berechnet useOptimistic laufend eine temporäre Überlagerung auf Basis des aktuellen, echten Zustands, sichtbar nur für die Dauer der jeweiligen Operation (Transition). Ist die Operation abgeschlossen – erfolgreich oder mit Fehler –, verschwindet die Überlagerung von selbst und legt frei, was in diesem Moment der tatsächliche Zustand ist. Du schreibst keinen Rollback-Code. Der Rollback ist eine natürliche Konsequenz des Verschwindens der temporären Überlagerung, kein separater Codepfad, den du vergessen oder falsch behandeln könntest.
jsx
"use client";import { useOptimistic, useTransition } from "react";import { toggleLikeAction } from "./actions";const LikeButton = ({ postId, liked, likeCount }) => {const [isPending, startTransition] = useTransition();const [optimistic, setOptimistic] = useOptimistic({ liked, likeCount },(state, nextLiked) => ({liked: nextLiked,likeCount: state.likeCount + (nextLiked ? 1 : -1),}),);const handleClick = () => {const nextLiked = !optimistic.liked;startTransition(async () => {setOptimistic(nextLiked);await toggleLikeAction(postId, nextLiked);// Kein manueller Rollback im catch nötig – wirft die Aktion einen Fehler,// verwirft React die Überlagerung trotzdem nach Abschluss der Transition// und legt das tatsächliche, in den Props übergebene "liked" frei.});};return (<buttontype="button"onClick={handleClick}aria-pressed={optimistic.liked}className={isPending ? "like-button--pending" : ""}><span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>{optimistic.likeCount}</button>);};export default LikeButton;
Der entscheidende Unterschied zur manuellen Version: Die von useOptimistic berechnete Überlagerung basiert auf dem als erstes Argument übergebenen state – also dem aktuellen, echten Zustand aus den Props (üblicherweise stammend aus einer Server-Action und revalidatePath, wie im Artikel über Server Actions) – und nicht auf einem separaten, manuell in einer lokalen Variable eingefrorenen Schnappschuss. Klickt der Nutzer zweimal hintereinander, berechnet jeder Aufruf von setOptimistic eine neue Überlagerung relativ zum aktuellen Zustand, sodass der zweite Klick nicht versehentlich von einem verspäteten Rollback des ersten überschrieben werden kann – es gibt keinen separaten Pfad „setze auf den gemerkten Wert zurück", der sich verwechseln könnte, welcher Zeitpunkt genau gemeint war.
Wann Optimistic UI eine gute Idee ist – und wann es eine gefährliche Lüge ist
Nicht jede Aktion eignet sich für dieses Muster, und das ist keine Geschmacksfrage, sondern eine Frage der Konsequenzen einer falschen Annahme. „Liken", „zu Favoriten hinzufügen", „als gelesen markieren" – billig in den Folgen, leicht umkehrbar, gelingen fast immer. Zahlung, unwiderrufliches Löschen eines Kontos, das Senden einer Nachricht an eine andere Person – hier ist es riskant, Erfolg optimistisch zu zeigen, bevor er tatsächlich eingetreten ist: Der Nutzer trifft weitere Entscheidungen auf Basis einer Information, die sich als falsch herausstellen kann, und das Zurücknehmen einer solchen „Lüge" wenige Sekunden später ist deutlich verwirrender als ein gewöhnlicher Spinner, der einfach etwas länger dauert.
Selbst dort, wo Optimistic UI Sinn ergibt, reproduziert ein stiller Rollback ohne jede Information genau das Wahrnehmungsproblem, das er eigentlich lösen sollte. Der Nutzer hat das gefüllte Herz gesehen, die Sache als erledigt betrachtet, die Aufmerksamkeit anderswohin verlagert – und einen Moment später kehrt das Herz still in den leeren Zustand zurück, ohne Erklärung. Das ist derselbe Fehler des „fehlenden Loops und Modus" aus Saffers Modell, nur zeitlich verschoben: Feedback für den Erfolg gab es, Feedback für den Fehlschlag nicht. Die Lösung ist dasselbe Werkzeug, das ich im Artikel über Barrierefreiheit beschrieben habe – aria-live="polite" oder role="alert" bei der Fehlermeldung, damit das Zurücksetzen des Zustands weder visuell noch akustisch für niemanden still verläuft, der gerade nicht genau auf diesen Button schaut, in dem Moment, in dem er zurückgesetzt wird.
Fallstricke und Best Practices
setOptimisticmuss innerhalb einer Transition aufgerufen werden. Außerhalb vonstartTransition(oder einer anuseActionState/<form action>übergebenen Aktion) funktioniertuseOptimisticnicht wie erwartet – der Mechanismus ist untrennbar mit dem Lebenszyklus einer konkreten Transition verbunden, nicht mit einem beliebigen Moment während des Renders.- Die an
useOptimisticübergebene Update-Funktion muss rein und günstig sein. Sie wird synchron während des Renders ausgeführt, um die Überlagerung zu berechnen – komplexe Berechnungen oder Seiteneffekte an dieser Stelle sind ein Kategoriefehler, nicht nur eine Performance-Frage. - Verstecke einen Fehlschlag nicht stillschweigend. Ein stiller Rollback ohne Mitteilung ist schlimmer als gar keine optimistische Aktualisierung – der Nutzer bekommt eine falsche Bestätigung und danach keinerlei Hinweis, dass doch etwas schiefgelaufen ist.
- Behalte das Muster für günstige, umkehrbare Aktionen vor. Wo die Kosten einer falschen Annahme hoch sind (Zahlungen, unwiderrufliche Vorgänge), ist ein gewöhnlicher Ladezustand mit klarer nachträglicher Bestätigung die bessere Wahl, nicht eine Oberfläche, die im Voraus rät.
Fazit
Optimistic UI ist kein Animationstrick – es ist die bewusste Annahme, dass eine Aktion gelingen wird, dem Nutzer gezeigt, bevor der Server das bestätigt hat. Eine manuelle Implementierung über setState und catch funktioniert so lange, bis jemand schneller klickt, als der Entwickler es vorhergesehen hat – dann kann der Rollback der einen Operation den Effekt der anderen überschreiben, weil sich beide denselben, eingefrorenen Zustandsschnappschuss teilen. useOptimistic beseitigt diese Fehlerklasse strukturell: Die Überlagerung wird immer relativ zum aktuellen, echten Zustand berechnet, nicht relativ zu einem separat gemerkten Wert, sodass der Rollback kein Code ist, den du schreibst und falsch schreiben kannst – er ist die natürliche Konsequenz des Verschwindens der temporären Annahme. Übrig bleibt eine Entscheidung, die dir keine API abnimmt: ob eine bestimmte Aktion günstig und umkehrbar genug ist, dass es überhaupt sinnvoll ist, in Bezug auf sie zu lügen, und sei es nur für einen Augenblick.