Optimistische Benutzeroberfläche ohne Asynchrone Probleme: Schnappschüsse, Rollbacks und abgebrochene Anfragen
Lernen Sie, optimistische Updates zu erstellen, die korrekt bleiben: Zustandsaufnahme, sofortige Aktualisierung, Rücksetzen bei Fehlern, Stornieren veralteter Anfragen sowie Erkennen von Situationen, in denen dieses Muster nicht angewendet werden sollte.
Bei einer schnellen Verbindung ist die Hin- und Rückreise hinter einem „Gefällt mir“-Button unsichtbar. In einem Zug, der zwischen schwachem LTE und kaum nutzbarem 3G hin- und herwechselt, kann dieselbe Hin- und Rückreise fünf bis zehn Sekunden dauern – in dieser Zeit bleibt das Herz „hohl“, während der Benutzer sich fragt, ob der Tap überhaupt registriert wurde. Optimistische Updates schließen diese Lücke, indem sie zunächst die Benutzeroberfläche ändern und erst anschließend den Server ansprechen. Diese Anleitung zeigt, wie dieses Muster in drei klaren Schritten umgesetzt werden kann, wie es vor konkurrierenden Anfragen geschützt wird und wie man entscheiden kann, wo es sinnvoll ist und wo es zu Nachteilen führt.
Das Problem, das sich hinter einem einzigen Tap verbirgt
Stellen Sie sich einen Beitrag vor, der 999 Likes hat und ein umrissenes Herz zeigt. In der strengsten Umsetzung sendet das Tippen auf das Herz eine Anfrage an einen Server, der sich auf einem anderen Kontinent befinden kann. Der Server authentifiziert den Anrufer, schreibt in eine Datenbank und antwortet – erst danach wird das Herz rot und die Anzahl der Likes steigt auf 1.000. Eine Wartezeit von ein paar hundert Millisekunden ist noch in Ordnung, doch fünf Sekunden sind unerträglich. Die Nutzer, die keine Reaktion sehen, tippen erneut – dann ein drittes Mal – und geben schließlich die App auf. Deshalb ist diese Verzögerung eine sehr wirksame Methode, um Nutzer zu verlieren.
Auch das Tippen löst mehr Aufwand aus als das Umstellen eines booleschen Werts. Eine typische Plattform führt im Hintergrund drei Aktionen aus:
- Sie ruft einen API-Endpunkt auf, der die Like-Zahl für diesen Beitrag erhöht.
- Sie fügt den Beitrag zu einer gespeicherten Sammlung im Konto des Nutzers hinzu, damit er später gefunden werden kann.
Sie alle drei hängen vom Netzwerk ab. Am Frontend muss die Berührung jedoch nur zwei Zustände ändern: ob das Herz gefüllt ist und die Zahl daneben. Wenn die Benutzeroberfläche auf jeden Serverseitigen Effekt wartet, bevor sie einen dieser Werte ändert, wirkt die App defekt, obwohl sie eigentlich korrekt funktioniert.
Optimismus als bewusche, wieder gutzumachende Lüge
Eine optimistische Aktualisierung löst dieses Problem, indem das Frontend sofort seinen eigenen Zustand ändert, unter der Annahme, dass die gerade gesendete Anfrage erfolgreich sein wird. Sobald der Finger den Bildschirm verlässt, ist das Herz rot und die Anzahl zeigt 1.000 an – obwohl noch nichts am Server angekommen ist. Diese kleine Unwahrheit sorgt dafür, dass die Interaktion sofortig erscheint.
Es ist nur akzeptabel, wenn es einen Plan gibt für den Fall, dass die Annahme nicht zutrifft. Der Zug könnte in einen Tunnel einfahren und die Verbindung unterbrechen, oder der Server könnte einen Fehler zurückgeben. Wenn das Display bei 1.000 bleibt, obwohl die Datenbank weiterhin 999 anzeigt, entsteht eine Desynchronisierung: Die Schnittstelle und die Quelle der Wahrheit sind sich nicht einig. Der Frontend muss dann in den tatsächlichen Zustand zurückkehren – mit einem umrissenen Herz und 999 – und den Benutzer in der Regel mit einer kurzen Nachricht, die kurz angezeigt wird und wieder verschwindet, wie zum Beispiel „Kann nicht gespeichert werden. Versuchen Sie es erneut.“, informieren.
Implementierung des Musters in drei Schritten
Ein Variablenwert zu ändern und darauf zu hoffen, dass die Anfrage funktioniert, ist eine Vermutung, keine gute Gestaltung. Eine zuverlässige optimistische Aktualisierung teilt drei Aufgaben auf: den aktuellen Zustand erfassen, das erwartete Ergebnis anzeigen und anschließend mit der Antwort des Servers abgleichen.
Schritt 1: Ein Screenshot erstellen
Bevor sich etwas auf dem Bildschirm ändert, speichern Sie die Werte, die der Benutzer derzeit sehen kann – hierbei geht es um das Like-Zustand des Beitrags sowie seine aktuelle Anzahl. Der Handler erhält den Beitrag, einen Zustandssetzer und eine Toast-Funktion sowie die API-Aufrufe, die er später ausführen wird:
// likeButton.js
import { updateLikeOnServer } from "./api.js";
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
// act two goes here
// act three goes here
}
Nach diesem Schritt haben Sie ein einziges, unverändertes Objekt, das genau beschreibt, was auf dem Bildschirm angezeigt wird, und ist somit bereit, bei Fehlern wiederhergestellt zu werden. Es ist wichtig, die beiden Felder in ein neues Objekt zu kopieren: Wenn Sie weiterhin auf ein veränderliches Objekt verweisen, das sich später ändert, würde auch der „Snapshot“ mit ihm zusammenändern.
Schritt 2: Das erwartete Ergebnis anwenden
Danach aktualisieren Sie die Benutzeroberfläche direkt, noch bevor der Netzwerkaufruf überhaupt beginnt:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
// act three goes here
}
Der Bildschirm zeigt nun das an, was der Benutzer angefordert hat – ein rotes Herz sowie eine höhere Anzahl – während der Server noch nichts weiß. Ein Punkt, auf den Sie in Ihrem eigenen Code achten sollten: In diesem Beispiel wird ein völlig neues Objekt mit nur liked und likeCount übergeben. Falls Ihr Setter das gesamte Objekt ersetzt anstelle der Felder zu kombinieren, verteilen Sie die vorhandenen Werte auf den neuen Wert, damit Eigenschaften wie id nicht verloren gehen.
Schritt 3: Abstimmung mit dem Server
Zum Schluss senden Sie die eigentliche Anfrage und entscheiden entweder, den optimistischen Zustand beizubehalten oder ihn aufzulösen. Die Anfrage befindet sich in einem try-Block; falls ein Fehler auftritt, stellt der Handler den ursprünglichen Zustand wieder her und zeigt eine Benachrichtigung an:
async function handleLikeClick(post, setPost, showToast) {
const previousState = { liked: post.liked, likeCount: post.likeCount };
setPost({ liked: true, likeCount: post.likeCount + 1 });
try {
await updateLikeOnServer(post.id);
} catch (error) {
setPost(previousState);
showToast("Couldn't save that. Try again.");
}
}
Der API-Hilfsfunktion wird fetch des Browsers verwendet. Beachten Sie, dass fetch nur bei Netzwerkfehlern abgelehnt wird, nicht bei HTTP-Fehlerstatusen. Deshalb prüft die Hilfsfunktion response.ok und wirft explizit einen Fehler aus. Ohne diese Prüfung würde eine 500-Antwort als Erfolg gewertet und der Rollback würde niemals ausgeführt werden:
// api.js
export async function updateLikeOnServer(postId) {
const response = await fetch(`/api/posts/${postId}/like`, {
method: "POST",
});
if (!response.ok) {
throw new Error("Like request failed");
}
return response.json();
}
Nach dem dritten Schritt gibt es nur zwei mögliche Ergebnisse. Entweder war die Anfrage erfolgreich und der optimistische Zustand ist nun wahr, oder sie fehlgeschlagen und die Benutzeroberfläche kehrt zum Snapshot vom ersten Schritt zurück. In beiden Fällen zeigt die Schnittstelle niemals etwas an, was der Backend länger als die Zeit, die für das Scheitern der Anfrage benötigt wird, nicht unterstützen kann.
Wo optimistische Aktualisierungen sich lohnen
Die zusätzliche Struktur lohnt sich nur dann, wenn ein Rollback kostengünstig ist und die wahrgenommene Geschwindigkeit sehr wichtig ist. Drei Kategorien entsprechen diesem Profil konsequent:
- Soziale Interaktionen. Likes, Follows und Reaktionen haben geringe Bedeutung. Wenn einer von vielen Tastenanschlägen pro Tag fehlschlägt, bedeutet das lediglich, dass der Benutzer erneut tippt.
- Messaging. In Chat-Apps wie WhatsApp oder Slack erscheint eine über eine schlechte Verbindung gesendete Nachricht sofort im Chat, oft verblasst oder mit einem kleinen grauen Uhr-Symbol. Das ist eine optimistische Anzeige mit sichtbarem Warteflag: Die Nachricht wird bereits auf dem Bildschirm angezeigt, bevor sie den Server erreicht hat.
- Drag-and-Drop-Plattformen. Das Ziehen einer Karte in die „Erledigt“-Spalte eines Kanban-Boards wie Jira oder Trello würde sich anfühlen, als würde man durch nassen Sand ziehen, wenn die Karte auf Bestätigung warten müsste. Im optimistischen Fall landet sie sofort dort, fällt bei einem fehlgeschlagenen Vorgang jedoch wieder in ihre ursprüngliche Spalte zurück.
Eine nützliche Verbesserung im Vergleich zum Messaging-Beispiel ist der Statusanzeiger für ausstehende Vorgänge. Dadurch wird deutlich, dass eine Aktion noch nicht bestätigt wurde, was es ermöglicht, gleichzeitig schnell und ehrlich zu handeln – was die Überraschung verringert, falls es zu einer Rückerstattung kommt.
Wo Optimismus gefährlich wird
Stellen Sie sich nun eine Bank-App vor. Ein Mieter tippt auf „Senden“, um fünftausend Dollar Miete an den Vermieter zu überweisen. Bei einer optimistischen Benachrichtigung zeigt das Display sofort an, dass die Überweisung erfolgreich war und der Saldo nun null ist; der Nutzer schließt die App in dem Glauben, die Miete sei bereits bezahlt.
Hinter den Kulissen scheitert der Antrag – ob aus Mangel an Mitteln oder wegen einer abgelehnten Überweisung, das spielt keine Rolle. Der Benutzer glaubt weiterhin, dass das Geld überwiesen wurde, bis der Vermieter anruft und nach der Miete fragt. Mindestens bedeutet das ein Support-Ticket sowie einen beschädigten Vertrauensverhältnis – und das passiert, weil ein für billige, rückgängig machbare Aktionen konzipiertes Muster auf einen Fall angewandt wurde, in dem Fehler echte Konsequenzen haben.
Dieselbe Vorsicht gilt für einige verwandte Kategorien:
- Finanzielle Transaktionen. Zeigen Sie einen Ladebalken an. Die Wartezeit signalisiert, dass das System die Operation tatsächlich überprüft hat, bevor es sie bestätigt.
- Unrückgängige Löschungen. Wenn jemand sein Konto löscht, sollte der Bildschirm erst dann leer werden, wenn der Server bestätigt, dass die Daten entfernt wurden.
Der entscheidende Faktor ist nicht, wie bedeutend eine Aktion erscheint, sondern wie teuer es ist, sich in Bezug darauf zu irren. Jemanden drei Sekunden länger warten zu lassen, während ein Ladeindikator angezeigt wird, ist weitaus besser, als ihm mitzuteilen, dass Geld überwiesen wurde, obwohl das nicht der Fall ist.
Wenn zwei Berührungen miteinander konkurrieren
Es gibt einen zweiten Fehlermodus, der nur dann auftritt, wenn der Benutzer vor Abschluss der ersten Anfrage erneut handelt. Im Beispiel mit dem langsamen Zug möge der Benutzer den Beitrag und verschiebt ihn von 999 auf 1.000, markiert ihn anschließend sofort wieder als ungeliebt und setzt den Wert auf 999 zurück. Angenommen, die Markierung als geliebt benötigt fünf Sekunden, während die Markierung als ungeliebt nur zwei Sekunden dauert – dann kann die Aktion zur Markierung als ungeliebt zuerst abgeschlossen werden.
Der Server verarbeitet anschließend das „Unlike“ für einen Beitrag, den er noch nicht als „Geliked“ betrachtet, sodass nichts geschieht, und ein paar Sekunden später verarbeitet er das „Like“. Auf dem Server wird der Account schließlich als geliked markiert, während der Bildschirm das Gegenteil von dem anzeigt, was der Benutzer zuletzt getan hat. Wenn der endgültige Zustand nach Reihenfolge des Eintreffens und nicht nach der Reihenfolge der Aktionen des Benutzers bestimmt wird, liegt eine Rennbedingung vor.
Abschalten von veralteten Anfragen mit AbortController
Die übliche Lösung ist das Abschalten: Wenn eine neuere Anfrage für denselben Ressourcen gestartet wird, soll der Browser die ältere Anfrage aufgeben. Der eingebaute AbortController kann eine bereits im Gange befindliche fetch-Anfrage abbrechen. Das Beispiel behält eine Referenz zum aktiven Controller bei, bricht diesen vor dem Start einer neuen Anfrage ab und wählt je nach gewünschtem Zustand POST oder DELETE.
// likeButton.js
let activeRequest = null;
async function toggleLike(postId, shouldLike) {
if (activeRequest) {
activeRequest.abort();
}
const controller = new AbortController();
activeRequest = controller;
await fetch(`/api/posts/${postId}/like`, {
method: shouldLike ? "POST" : "DELETE",
signal: controller.signal,
});
}
Jeder neue Tap storniert die vorherige Anfrage, sodass nur die neueste Aktion durchgeführt wird. Bevor man sich ausschließlich darauf verlässt, sollten zwei Punkte beachtet werden:
- Durch das Abbrechen wird verhindert, dass der Browser auf die Antwort wartet; dies garantiert jedoch nicht, dass der Server die Anfrage nie erhalten oder verarbeitet hat. Für stärkere Garantien sollte der Endpunkt so gestaltet werden, dass er den gewünschten Endzustand (gefällt oder nicht gefällt) berücksichtigt, sodass wiederholte oder umgeordnete Anfragen zum gleichen Ergebnis führen, oder ein Sequenznummer hinzugefügt werden kann, die der Server nutzen kann, um veraltete Schreibvorgänge zu ignorieren.
- Eine abgebrochene
fetch-Anfrage führt zu einem Fehler vom TypAbortError. Wenn man dies mit dem Rollback aus Schritt drei kombiniert, sollte dieser Fehler überprüft und der Rollback übersprungen werden; andernfalls würde das Stornieren einer veralteten Anfrage den neueren, optimistischen Zustand fälschlicherweise rückgängig machen.
In React-Anwendungen übernehmen Datenbibliotheken einen großen Teil dieser Aufgaben. TanStack Query, das den Caching von Server-Zuständen sowie die Lebenszyklen von Anfragen verwalten, und Redux Toolkit, das offizielle Werkzeugset zur Erstellung von Redux-Logik, bieten beide eingebaute Unterstützung für optimistische Aktualisierungen und Stornierungen, sodass man selten manuell AbortController-Konfigurationen schreiben muss. Prüfen Sie in ihren Dokumentationen, was in jedem Fall gestoppt wird – es bleibt dennoch wertvoll, das zugrunde liegende Mechanismus zu verstehen. Für eine detailliertere Betrachtung, wie Rollbacks mit Reacts eigenem Hook schiefgehen können, sehen Sie fünf Fehlermodi bei optimistischen Rollbacks.
Kernpunkte
- Eine Netzwerkverbindungsaufnahme kann so langsam sein, dass eine funktionierende Anwendung wie defekt erscheint; daher zeigen optimistische Aktualisierungen zunächst das erwartete Ergebnis und bestätigen es später.
fetch lehnt bei HTTP-Fehlern keine Anfragen ab, daher werfen Sie einen Fehler bei !response.ok aus und informieren Sie den Benutzer mit einer Benachrichtigung beim Rollback.AbortError in der Rollback-Logik ignorieren und Endpunkte bevorzugen, die einen endgültigen Zustand festlegen anstatt ihn nur umzuschalten.Geschwindigkeit ist eine Eigenschaft, die das Netzwerk Ihnen nicht umsonst gibt. Eine optimistische Schnittstelle akzeptiert, dass sie die Verbindung nicht kontrollieren kann, antwortet dem Benutzer dennoch zuversichtlich und bleibt bereit, diese Antwort sofort zurückzunehmen, sobald sich herausstellt, dass sie falsch war.