Optimistic Updates jenseits von useOptimistic: Rollback mit TanStack Query
AI generated
</>
{ }
React · TanStack Query · State Management
Optimistic Updates jenseits von useOptimistic
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.

18 Min. Lesezeit onMutate · Snapshot-Rollback · Race Conditions · Undo-Pattern React 19 · TanStack Query v5

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.

11. FAQ: Optimistic Updates jenseits von useOptimistic

1Wann reicht useOptimistic nicht mehr?
Sobald derselbe Datensatz in mehreren Komponenten sichtbar sein muss, useOptimistic kennt keinen geteilten Cache.
2Was macht onMutate genau?
Läuft vor der Netzwerkanfrage, sichert den Zustand und setzt den optimistischen Wert per setQueryData.
3Warum ein Snapshot statt einfachem Zurücksetzen?
Ohne exakten Snapshot ist unklar, welcher Wert der korrekte Ausgangszustand war.
4Was verhindert cancelQueries?
Storniert laufende Refetches, damit eine veraltete Antwort das optimistische Update nicht überschreibt.
5Abgeleiteten Zähler optimistisch aktualisieren?
Am robustesten direkt aus der Listenlänge berechnen, statt als separaten Query-Eintrag zu pflegen.
6onMutate vs. optimisticResponse?
onMutate ist manuell und explizit, optimisticResponse deklarativ und an GraphQL gebunden.
7Immer ein Undo-Pattern verwenden?
Nur bei Aktionen mit akzeptabler kurzer Verzögerung, etwa Löschen. Sonst bleibt Snapshot-Rollback richtig.
8Wie teste ich ein Rollback?
Mit MSW einen Fehler simulieren und prüfen, ob der Cache exakt dem Zustand vor der Mutation entspricht.
9Was passiert ohne onSettled?
Der Cache bleibt im optimistischen Zustand, ohne mit dem tatsächlichen Server-Ergebnis abgeglichen zu werden.
10Braucht jede Mutation ein Optimistic Update?
Nein, seltene Hintergrundaktionen reichen mit normalem Refetch. Lohnt sich vor allem bei häufigen Interaktionen.