Rollback-Strategien mit TanStack Query
Reacts useOptimistic-Hook löst einen einzelnen, lokalen Anwendungsfall elegant, scheitert aber an verschachtelten Listen, parallelen Mutationen und Race Conditions über mehrere Komponenten hinweg. Dieser Artikel zeigt, wie Optimistic Updates mit TanStack Query Mutations, Snapshot-basiertem Rollback und expliziter Query-Cancellation in echten Anwendungen robust werden.
Inhaltsverzeichnis
- 1. Warum useOptimistic allein nicht reicht
- 2. useOptimistic kurz rekapituliert: die Grenzen des Hooks
- 3. TanStack Query Mutations: onMutate, Snapshot und Rollback
- 4. Race Conditions bei parallelen Optimistic Updates
- 5. Verschachtelte Optimistic Updates: Listen und Zähler
- 6. Fehlerbehandlung: Toast, Retry und Undo-Pattern
- 7. Optimistic Updates mit Apollo Client im Vergleich
- 8. Testing von Optimistic Updates mit MSW und Vitest
- 9. Optimistic-Update-Strategien im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum useOptimistic allein nicht reicht
Optimistic Updates lösen ein einfaches Problem: Statt auf die Serverantwort zu warten, aktualisiert die UI sofort, sobald eine Nutzerin eine Aktion auslöst, und korrigiert sich nur, falls der Server tatsächlich einen Fehler zurückmeldet. Reacts useOptimistic-Hook deckt den einfachsten Fall dieser Idee ab: ein einzelner State-Wert, eine einzelne Aktion, ein einzelner Erfolgspfad. Für ein Like-Icon oder ein Formularfeld reicht das vollkommen aus.
Sobald jedoch mehrere Listeneinträge gleichzeitig aktualisiert werden, mehrere Mutationen parallel laufen oder das aktualisierte Datum in mehreren Komponenten gleichzeitig sichtbar sein muss, stößt useOptimistic an seine Grenzen. Der Hook kennt keinen globalen Cache, keine Query-Invalidierung und kein eingebautes Konzept für Race Conditions zwischen zwei überlappenden Optimistic Updates. Dieser Artikel zeigt, wie TanStack Query diese Lücke mit onMutate, Snapshot-Rollback und expliziter Query-Cancellation schließt.
2. useOptimistic kurz rekapituliert: die Grenzen des Hooks
useOptimistic nimmt einen aktuellen State-Wert und eine Reducer-Funktion entgegen und liefert einen abgeleiteten, optimistischen Wert zurück, der sofort aktualisiert wird, sobald eine Transition mit dem neuen Wert gestartet wird. Solange die umschließende action läuft, zeigt die Komponente den optimistischen Wert an. Schlägt die Aktion fehl, springt React automatisch zum vorherigen Wert zurück, sobald die Transition beendet ist. Das ist elegant für lokale, komponentenscoped Zustände.
Das Problem entsteht, sobald derselbe Datensatz an mehreren Stellen im Baum benötigt wird, etwa eine Todo-Liste und ein separates Dashboard mit einem Zähler offener Todos. useOptimistic kennt keinen geteilten Cache: Jede Komponente müsste den optimistischen Zustand eigenständig verwalten, was zu Inkonsistenzen führt, sobald zwei Komponenten unterschiedliche Annahmen über den aktuellen Zustand treffen. Für Optimistic Updates, die über Komponentengrenzen hinweg konsistent bleiben müssen, ist ein zentraler Cache wie der von TanStack Query oder Apollo Client die robustere Wahl.
// TodoItem.tsx — useOptimistic works well for a single, local value
import { useOptimistic, startTransition } from "react";
function TodoItem({ todo, toggleTodo }) {
const [optimisticTodo, setOptimisticTodo] = useOptimistic(
todo,
(state, done) => ({ ...state, done })
);
function handleToggle() {
startTransition(async () => {
setOptimisticTodo(!optimisticTodo.done);
await toggleTodo(todo.id); // if this throws, React reverts automatically
});
}
// Limitation: a sibling counter component has no access to this
// optimistic state, it only sees the real state until the mutation settles.
return <button onClick={handleToggle}>{optimisticTodo.done ? "Erledigt" : "Offen"}</button>;
}
3. TanStack Query Mutations: onMutate, Snapshot und Rollback
TanStack Query löst genau dieses Problem, weil Optimistic Updates direkt im zentralen Query-Cache stattfinden, den alle Komponenten über useQuery lesen. Die onMutate-Callback-Funktion einer Mutation läuft, bevor die Netzwerkanfrage überhaupt gesendet wird, und kann mit queryClient.setQueryData den Cache direkt aktualisieren. Jede Komponente, die diese Query liest, sieht die Änderung sofort, unabhängig davon, wo im Baum sie sich befindet.
Der entscheidende Schritt für Robustheit ist das Snapshot-Pattern: Bevor der Cache verändert wird, wird der aktuelle Zustand mit queryClient.getQueryData gesichert und aus onMutate zurückgegeben. Schlägt die Mutation fehl, stellt onError diesen Snapshot exakt wieder her. Ohne diesen Schritt bleibt ein fehlgeschlagenes Optimistic Update dauerhaft im Cache stehen und die UI zeigt einen Zustand, der nie tatsächlich bestätigt wurde.
// useToggleTodo.ts — TanStack Query mutation with snapshot-based rollback
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.toggleTodo(id),
onMutate: async (id) => {
// Cancel outgoing refetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey: ["todos"] });
const previousTodos = queryClient.getQueryData(["todos"]);
queryClient.setQueryData(["todos"], (old) =>
old.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
// Returned here, available as context in onError
return { previousTodos };
},
onError: (_err, _id, context) => {
// Roll back to the exact snapshot taken before the optimistic update
queryClient.setQueryData(["todos"], context.previousTodos);
},
onSettled: () => {
// Always resync with the server, whether it succeeded or failed
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
4. Race Conditions bei parallelen Optimistic Updates
Ein subtiles Problem entsteht, wenn eine Nutzerin ein Element mehrfach hintereinander schnell anklickt, etwa ein Like-Icon, während die vorherige Mutation noch läuft. Ohne Schutzmaßnahme kann ein im Hintergrund laufender Refetch der alten Query-Daten das gerade gesetzte Optimistic Update überschreiben, sobald die Netzwerkantwort eintrifft, bevor die Mutation selbst abgeschlossen ist. Das Ergebnis ist ein kurzes, verwirrendes Zurückspringen der UI, das Nutzerinnen als Bug wahrnehmen, auch wenn der Endzustand am Ende korrekt ist.
queryClient.cancelQueries in onMutate ist der zentrale Schutzmechanismus gegen dieses Problem. Der Aufruf storniert alle aktuell laufenden Refetches für den betroffenen Query-Key, bevor der optimistische Wert gesetzt wird, sodass kein veralteter Refetch das Optimistic Update überschreiben kann. Für Mutationen, die schnell mehrfach ausgelöst werden können, etwa ein Zähler mit Plus- und Minus-Buttons, empfiehlt sich zusätzlich, den optimistischen Wert relativ statt absolut zu berechnen, damit sich mehrere schnelle Klicks korrekt aufaddieren, statt sich gegenseitig zu überschreiben.
5. Verschachtelte Optimistic Updates: Listen und Zähler
Bei einer Liste mit einem abgeleiteten Zähler, etwa einer Todo-Liste mit einer Anzeige der offenen Einträge, muss ein Optimistic Update beide Werte konsistent aktualisieren. Wird nur die Liste, aber nicht der Zähler optimistisch aktualisiert, zeigt die UI kurzzeitig widersprüchliche Informationen an. Die robuste Lösung ist, den Zähler als abgeleiteten Wert direkt aus der Liste zu berechnen, statt ihn als eigenen Query-Eintrag zu pflegen, wodurch ein einziges setQueryData auf die Liste automatisch auch den Zähler korrekt aktualisiert.
Für tief verschachtelte Strukturen, etwa Kommentare unter einem Beitrag mit eigenen Zähler-Feldern, lohnt sich eine kleine Hilfsfunktion, die den Update-Pfad zentral kapselt, statt die Verschachtelungslogik in jeder onMutate-Funktion zu wiederholen. Diese Kapselung reduziert die Fehleranfälligkeit erheblich, weil Änderungen an der Datenstruktur nur an einer Stelle nachgezogen werden müssen, statt in jeder einzelnen Mutation, die Optimistic Updates auf denselben verschachtelten Daten durchführt.
// useAddComment.ts — Optimistic update on a nested list with a derived counter
function useAddComment(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (text: string) => api.addComment(postId, text),
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ["post", postId] });
const previousPost = queryClient.getQueryData(["post", postId]);
queryClient.setQueryData(["post", postId], (old) => ({
...old,
comments: [...old.comments, { id: `temp-${Date.now()}`, text, pending: true }],
// commentCount is derived here, so it never drifts from the list length
commentCount: old.comments.length + 1,
}));
return { previousPost };
},
onError: (_err, _text, context) => {
queryClient.setQueryData(["post", postId], context.previousPost);
},
});
}
6. Fehlerbehandlung: Toast, Retry und Undo-Pattern
Ein reines Rollback ohne Nutzerfeedback lässt Nutzerinnen im Unklaren darüber, warum ihre Aktion plötzlich verschwunden ist. Zusätzlich zum Snapshot-Rollback in onError gehört zu robusten Optimistic Updates immer eine sichtbare Fehlermeldung, meist über einen Toast, der erklärt, dass die Aktion fehlgeschlagen ist, kombiniert mit einer Retry-Option. Ohne dieses Feedback wirkt das kurze Aufblitzen und Verschwinden der Änderung wie ein Darstellungsfehler der Anwendung.
Ein weiteres bewährtes Muster ist Undo statt sofortiger Serveranfrage: Eine Aktion wie das Löschen einer E-Mail wird sofort optimistisch in der UI ausgeführt, die tatsächliche Serveranfrage aber um einige Sekunden verzögert, während ein Undo-Button eingeblendet wird. Klickt die Nutzerin auf Undo, wird die Serveranfrage nie ausgelöst und das Optimistic Update einfach zurückgenommen. Dieses Muster reduziert echte Serveranfragen und gibt Nutzerinnen eine bewusste Korrekturmöglichkeit, statt sich auf Fehlerbehandlung im Nachhinein zu verlassen.
7. Optimistic Updates mit Apollo Client im Vergleich
Apollo Client verfolgt mit optimisticResponse ein ähnliches Prinzip wie TanStack Query, aber tiefer in den normalisierten GraphQL-Cache integriert. Statt eine onMutate-Funktion manuell zu schreiben, deklariert man direkt bei der Mutation, wie die erwartete optimistische Antwort aussieht, und Apollo übernimmt automatisch Cache-Update und Rollback bei Fehlern. Für Teams, die bereits mit Apollo arbeiten, ist dieser deklarative Ansatz oft weniger Code als das manuelle Snapshot-Pattern von TanStack Query.
Der Nachteil von Apollos Ansatz ist, dass er GraphQL und eine normalisierte Cache-Struktur voraussetzt, während TanStack Query mit jeder Datenquelle funktioniert, REST, GraphQL oder direkte Funktionsaufrufe. Für Optimistic Updates auf REST-Endpoints bleibt TanStack Query die praktikablere Wahl, weil es keine GraphQL-Schicht als Voraussetzung hat und die Snapshot-Logik explizit und nachvollziehbar im eigenen Code liegt.
8. Testing von Optimistic Updates mit MSW und Vitest
Optimistic Updates sind besonders testrelevant, weil der interessante Fall nicht der Erfolgspfad ist, sondern das korrekte Rollback bei einem Fehler. Mit Mock Service Worker lässt sich ein Endpoint gezielt so konfigurieren, dass er einen Fehler zurückgibt, und der Test prüft, ob die UI nach dem fehlgeschlagenen Optimistic Update exakt zum vorherigen Zustand zurückkehrt, statt in einem inkonsistenten Zwischenzustand hängen zu bleiben.
Ein zweiter wichtiger Testfall ist die Race-Condition-Situation aus Abschnitt vier: Zwei schnell aufeinanderfolgende Mutationen sollten am Ende denselben Endzustand erzeugen, unabhängig von der Reihenfolge, in der die Netzwerkantworten eintreffen. Solche Tests mit künstlich verzögerten MSW-Handlern decken Bugs auf, die im manuellen Testen mit schneller lokaler Verbindung praktisch nie sichtbar werden, in Produktion mit variabler Netzwerklatenz aber regelmäßig auftreten.
// toggleTodo.test.tsx — Verifying rollback after a failed optimistic update
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { server } from "../mocks/server";
test("rolls back optimistic update on server error", async () => {
server.use(
http.patch("/api/todos/:id", () => HttpResponse.json({ error: "failed" }, { status: 500 }))
);
const { result } = renderHook(() => useToggleTodo(), { wrapper: createWrapper() });
result.current.mutate("todo-1");
// Optimistic value appears immediately
expect(getCachedTodo("todo-1").done).toBe(true);
// After the failed request, the cache must match the pre-mutation snapshot
await waitFor(() => expect(getCachedTodo("todo-1").done).toBe(false));
});
9. Optimistic-Update-Strategien im direkten Vergleich
Die folgende Tabelle stellt die drei behandelten Ansätze für Optimistic Updates gegenüber, mit Fokus auf Reichweite im Komponentenbaum und Rollback-Verhalten.
| Ansatz | Reichweite | Rollback | Race-Condition-Schutz |
|---|---|---|---|
| useOptimistic | Nur lokale Komponente | Automatisch, aber lokal | Keiner eingebaut |
| TanStack Query | Globaler Cache, alle Komponenten | Manuell via Snapshot in onError | cancelQueries |
| Apollo Client | Globaler normalisierter Cache | Automatisch | Eingebaut, GraphQL-spezifisch |
Für einzelne, komponentenlokale Interaktionen bleibt useOptimistic die einfachste Lösung ohne zusätzliche Abhängigkeit. Sobald derselbe Datensatz jedoch in mehreren Komponenten sichtbar sein muss oder Race Conditions bei schnellen Interaktionen ein reales Risiko darstellen, liefern TanStack Query und Apollo Client die robusteren Werkzeuge für produktionsreife Optimistic Updates.
Mironsoft
React-State-Management und produktionsreife Optimistic-Update-Patterns
UI, die sofort reagiert und trotzdem konsistent bleibt?
Wir bauen Snapshot-basierte Rollback-Strategien, Race-Condition-Schutz und Undo-Patterns für eure TanStack-Query- oder Apollo-Client-Mutations.
Mutation-Audit
Analyse bestehender Mutationen auf fehlendes Rollback und Race Conditions
Undo-Pattern
Verzögerte Serveranfragen mit Undo-Button für kritische Aktionen
Test-Suite
MSW und Vitest Tests für Rollback- und Race-Condition-Szenarien
10. Zusammenfassung
Optimistic Updates mit useOptimistic lösen den einfachen, lokalen Fall elegant, stoßen aber an Grenzen, sobald derselbe Datensatz in mehreren Komponenten sichtbar sein muss. TanStack Query löst dieses Problem über den globalen Query-Cache mit onMutate, Snapshot-Rollback in onError und cancelQueries als Schutz gegen Race Conditions. Verschachtelte Strukturen wie Listen mit abgeleiteten Zählern profitieren davon, den Zähler direkt aus den Listendaten zu berechnen, statt ihn separat zu pflegen.
Apollo Clients optimisticResponse bietet einen deklarativeren Weg für GraphQL-basierte Anwendungen mit automatischem Rollback. Für Nutzerfeedback gehören sichtbare Fehlermeldungen und, wo sinnvoll, Undo-Patterns zu robusten Optimistic Updates dazu. Tests mit MSW, die gezielt Fehler simulieren, stellen sicher, dass Rollback und Race-Condition-Schutz tatsächlich funktionieren, bevor sie in Produktion überrascht auffallen.
Optimistic Updates jenseits von useOptimistic — Das Wichtigste auf einen Blick
Grenzen von useOptimistic
Nur lokaler Komponentenzustand, kein geteilter Cache zwischen Komponenten.
Snapshot-Rollback
Zustand vor der Änderung in onMutate sichern, in onError exakt wiederherstellen.
Race Conditions
cancelQueries vor jedem optimistischen Update verhindert überschreibende Refetches.
Nutzerfeedback
Toast bei Fehlschlag, Undo-Pattern für kritische, verzögerbare Aktionen.