post

Client-Zustand vs. Server-Cache: die folgenreichste Grenze im Frontend

Das meiste, was in einer React-Anwendung wie Zustand aussieht, ist gar keiner – es sind Serverdaten, die man nur zwischengespeichert hat. Warum diese Unterscheidung so viel entscheidet, warum useEffect dafür das falsche Werkzeug ist und was eine Server-State-Bibliothek besser macht.

Client-Zustand vs. Server-Cache: die folgenreichste Grenze im Frontend

Im React-Überblick habe ich eine Frage in den Mittelpunkt gestellt: Wo gehört ein Zustand hin? Vier Orte habe ich unterschieden – die Komponente, die URL, ein globaler Store und, fast beiläufig, den Server-Cache. Diese letzte Grenze verdient einen eigenen Beitrag, weil sie im Alltag die meisten Fehler verursacht und weil man sie so leicht übersieht. Denn das meiste, was in einer Anwendung wie Zustand aussieht, ist gar keiner. Es sind Daten, die dem Backend gehören und die man im Browser nur zwischengespeichert hat.

Diese Verwechslung ist kein Anfängerfehler, den man sich abgewöhnt. Ich sehe sie in Schulungen bei Leuten, die seit Jahren React schreiben, und ich habe sie selbst lange gemacht. Der Unterschied klingt akademisch, hat aber ganz praktische Folgen: Er entscheidet, welches Werkzeug man wählt, wie viele Bugs man später jagt und wie klein oder groß der Teil der Anwendung wird, den man von Hand verwalten muss.

Zwei Sorten Zustand, die man ständig verwechselt

Fangen wir mit einer einfachen Frage an: Wo lebt die Wahrheit? Bei echtem Client-Zustand lebt sie im Browser. Der Text in einem Eingabefeld, ob ein Menü auf- oder zugeklappt ist, welcher Tab gerade aktiv ist – all das entsteht durch eine Nutzerinteraktion und existiert nur hier. Niemand außerhalb dieses Browsers kennt diesen Wert oder braucht ihn. Wenn die Komponente verschwindet, verschwindet er mit, und das ist völlig in Ordnung.

Bei Serverdaten ist es umgekehrt. Die Liste der Rechnungen, das Nutzerprofil, der Warenbestand – die Wahrheit dazu lebt im Backend, in einer Datenbank, hinter einer API. Was im Browser liegt, ist nur eine Kopie. Und Kopien haben eine unangenehme Eigenschaft: Sie veralten. In der Sekunde, in der der Server die Antwort abschickt, kann jemand anderes den Datensatz schon geändert haben. Der Browser hält dann einen Abzug, der aussieht wie die Wahrheit, es aber nicht mehr ist.

flowchart TB
  Frage["Wo lebt die Wahrheit?"]
  Frage --> Client["im Browser<br/>= Client-Zustand"]
  Frage --> Server["im Backend<br/>= Serverdaten"]
  Client --> C1["Eingaben, Auswahl,<br/>aufgeklappte Menüs"]
  Server --> S1["Der Browser hält nur<br/>eine Kopie – sie veraltet"]

Sobald man diese Frage einmal stellt, sortiert sich vieles neu. Der Zähler, das Formular, die Filterauswahl – Client-Zustand. Die geladene Liste, das Suchergebnis, der Kontostand – Serverdaten, also ein Cache. Und für einen Cache gelten andere Regeln als für Zustand, den man selbst besitzt.

Warum useEffect für Serverdaten das falsche Werkzeug ist

Der übliche erste Reflex sieht so aus: Man nimmt useState für die Daten, useEffect für das Laden, und fertig. Genau dieses Muster habe ich im Überblick als Beispiel gezeigt – bewusst mit einer Aufräumfunktion, damit es überhaupt korrekt ist:

function useInvoices() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    fetch("/api/invoices")
      .then((r) => r.json())
      .then((d) => active && setData(d))
      .catch((e) => active && setError(e.message));
    return () => {
      active = false;
    };
  }, []);

  return { data, error };
}

Das funktioniert – für genau eine Komponente, die genau einmal lädt. Sobald die Anwendung wächst, fehlt hier fast alles, was Serverdaten wirklich brauchen. Zwei Komponenten, die dieselbe Liste anzeigen, laden sie zweimal, weil keine von der anderen weiß. Wechselt man weg und kommt zurück, wird von vorne geladen, obwohl die Daten von eben noch gut genug wären. Es gibt keinen Weg, die Kopie nach einer Änderung gezielt zu erneuern. Und der Wettlauf zwischen einer alten und einer neuen Anfrage, den die Aufräumfunktion oben entschärft, ist nur der offensichtlichste einer ganzen Familie von Problemen.

Ein Beispiel, das ich in Schulungen gern zeige: ein Dashboard, oben eine Zahl „offene Rechnungen: 12", daneben eine Liste derselben Rechnungen. Beide Bausteine laden ihre Daten in einem eigenen Effekt. Schon beim Start feuern zwei Anfragen, wo eine gereicht hätte. Ärgerlicher wird es, wenn jemand eine Rechnung bezahlt: Die Liste lädt neu und zeigt elf, die Zahl oben lädt nicht neu und zeigt weiter zwölf. Zwei Kopien derselben Wahrheit, die auseinanderlaufen – und niemand hat einen Fehler gemacht, der Code ist nur ehrlich zu Ende gedacht. Genau solche Widersprüche entstehen zwangsläufig, wenn jede Komponente ihren eigenen kleinen Cache hält, ohne dass es eine gemeinsame Stelle gibt, die weiß, welche Daten gerade gültig sind.

Der eigentliche Punkt ist ein anderer. Ein Effekt synchronisiert eine Komponente mit etwas außerhalb von React – das ist seine Aufgabe, und dafür ist er richtig. Aber das Verwalten eines Caches ist kein Randthema einer einzelnen Komponente. Es ist ein anwendungsweites Problem: Wer hält welche Daten, wie alt dürfen sie sein, wann werden sie erneuert, wer teilt sie sich? Dieses Problem in jedem useEffect einzeln und von Hand zu lösen, ist ungefähr so, als würde man in jeder Funktion eine eigene kleine Datenbank schreiben. Es geht, aber es skaliert nicht, und jede Kopie hat ihre eigenen Bugs.

Was eine Server-State-Bibliothek übernimmt

An dieser Stelle lohnt sich ein Werkzeug, das genau für diesen Fall gebaut ist. TanStack Query (früher React Query) und SWR sind die beiden verbreiteten Vertreter. Sie teilen dieselbe Grundidee, die man am Namen von SWR ablesen kann: stale-while-revalidate. Zeig die vorhandene, womöglich veraltete Kopie sofort an, und erneuere sie im Hintergrund. Der Nutzer sieht sofort Daten, und kurz darauf die frischen.

Der Kern ist ein zentraler Cache, der Daten unter einem Schlüssel ablegt. Man beschreibt nur, was man will und woher es kommt:

import { useQuery } from "@tanstack/react-query";

function useInvoices() {
  return useQuery({
    queryKey: ["invoices"],
    queryFn: () => fetch("/api/invoices").then((r) => r.json()),
    staleTime: 30_000,
  });
}

Das sieht kaum kürzer aus als der useEffect, aber dahinter steckt eine ganze Maschinerie. Fragen zwei Komponenten ["invoices"] gleichzeitig ab, wird trotzdem nur eine Anfrage geschickt – das ist Deduplizierung. Kommt eine Komponente zurück, deren Daten jünger als die staleTime sind, wird gar nicht neu geladen. Sind sie älter, zeigt die Bibliothek die alte Kopie sofort und lädt im Hintergrund nach. Der queryKey ist dabei mehr als ein Name: Er ist die Identität der Daten im Cache und der Angelpunkt für alles Weitere.

Am deutlichsten wird der Unterschied beim Schreiben. Nach einer Mutation muss der Cache erfahren, dass seine Kopie jetzt falsch ist:

import { useMutation, useQueryClient } from "@tanstack/react-query";

function usePayInvoice() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id) => fetch(`/api/invoices/${id}/pay`, { method: "POST" }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["invoices"] });
    },
  });
}

Der Aufruf invalidateQueries markiert alle Abfragen unter ["invoices"] als veraltet und lädt sie neu. Man sagt also nicht mehr „setze diesen Zustand", sondern „diese Serverdaten sind jetzt unzuverlässig, hol sie dir frisch". Das ist genau die richtige Denkweise für einen Cache: Man besitzt die Daten nicht, man hält sie nur vorläufig, und nach einer Änderung erklärt man die Kopie für ungültig. Und weil beide Bausteine aus dem Dashboard-Beispiel denselben queryKey benutzen, aktualisiert diese eine Zeile sie gemeinsam – die Zahl oben und die Liste daneben können gar nicht mehr auseinanderlaufen.

Wer will, geht einen Schritt weiter und aktualisiert die Oberfläche schon, bevor der Server geantwortet hat. Über den onMutate-Rückruf schreibt man den erwarteten neuen Stand direkt in den Cache, sodass die Rechnung sofort als bezahlt erscheint; kommt der Server dann mit einem Fehler zurück, rollt die Bibliothek den Cache auf den alten Stand zurück. Dieses optimistische Aktualisieren macht Oberflächen spürbar schneller, und es ist überhaupt nur möglich, weil die Daten an einer zentralen, benannten Stelle liegen und nicht in einem Dutzend einzelner useState-Werte verstreut sind.

flowchart LR
  A["Komponente fragt<br/>queryKey"] --> B{"Kopie im Cache<br/>und noch frisch?"}
  B -->|ja| C["sofort anzeigen,<br/>nichts laden"]
  B -->|veraltet| D["alte Kopie zeigen +<br/>im Hintergrund neu laden"]
  D --> E["frische Daten<br/>ersetzen die Kopie"]

Dazu kommt eine ganze Reihe Kleinigkeiten, die man sonst von Hand bauen müsste: erneutes Laden, wenn das Fenster wieder den Fokus bekommt, Wiederholung bei Netzfehlern, pending- und error-Zustände als sauberes Ergebnis statt als drei einzelne useState, und Werkzeuge, die den Cache im Browser sichtbar machen. Nichts davon ist Zauberei – es ist genau die Buchhaltung, die ein Cache braucht und die in einem useEffect immer fehlt.

SWR oder TanStack Query?

Beide Bibliotheken lösen dasselbe Problem, setzen aber unterschiedliche Schwerpunkte. SWR ist bewusst klein – rund vier Kilobyte, eine schmale API, schnell verstanden. Man sagt ihr, wie lange Daten als frisch gelten, und ruft mutate(key) auf, um sie zu erneuern. Für lese-lastige Oberflächen mit einfachen Schreibvorgängen ist das oft genau richtig, gerade im Next.js-Umfeld.

TanStack Query ist umfangreicher und gibt mehr Kontrolle: staleTime und gcTime trennen sauber, wie lange Daten frisch bleiben und wann sie aus dem Speicher fallen; invalidateQueries kann über einen Präfix ganze Familien von Abfragen auf einmal für veraltet erklären, was bei verschachtelten Daten viel wert ist; und useMutation ist eine vollständige Zustandsmaschine mit Rückrufen für optimistische Updates. Der Preis ist etwas mehr Gewicht und ein paar Stellschrauben mehr. Meine Faustregel: Für kleine, überschaubare Fälle SWR, für alles, das an Server-State-Komplexität zu wachsen droht, TanStack Query.

Der wichtigere Punkt liegt aber quer zu dieser Wahl: Sobald man Serverdaten als das behandelt, was sie sind – einen Cache mit Schlüssel, Alter und Invalidierung –, verschwindet das ganze useState-und-useEffect-Gebastel, egal für welche der beiden man sich entscheidet.

Was dann noch echter Client-Zustand ist

Jetzt kommt die eigentliche Pointe, und sie überrascht Teilnehmer regelmäßig. Wenn man die Serverdaten aus der Zustandsverwaltung herauszieht, bleibt erschreckend wenig übrig. Ich mache in Schulungen gern die Gegenprobe: Wir listen alles auf, was das Team für „globalen Zustand" hält, und streichen dann Punkt für Punkt. Die geladenen Listen? Cache. Die Detaildaten? Cache. Das Suchergebnis? Cache. Welcher Filter aktiv ist, welche Seite man sieht, welcher Datensatz ausgewählt ist? Das gehört in die URL – teilbar, mit Lesezeichen speicherbar, überlebt einen Reload.

Was am Ende als echter Client-Zustand übrig bleibt, ist ein kleiner Kern: der Text in einem Formular, während man noch tippt, ein paar UI-Details wie aufgeklappte Bereiche, und vielleicht eine Handvoll wirklich app-weiter Entscheidungen – die angemeldete Person, das Theme, ein Warenkorb. Für diesen Rest reicht fast immer useState, gelegentlich Context oder ein leichter Store. Ein großer globaler Store ist selten die Antwort – meistens war das, was ihn füllen sollte, in Wahrheit Server-Cache, der dort nichts zu suchen hatte.

Das ist der Grund, warum ich diese Grenze für die folgenreichste im ganzen Frontend halte. Sie entscheidet nicht nur über ein Werkzeug. Sie räumt die halbe Zustandsverwaltung ab, bevor man sie überhaupt baut.

React 19: use() und Suspense

Ein Ausblick, weil sich hier gerade etwas verschiebt. React 19 bringt mit dem use()-Hook und Suspense einen weiteren Weg, Serverdaten ins Rendern zu holen: Man reicht einer Komponente ein Promise, use() wartet darauf, und eine umschließende <Suspense>-Grenze zeigt so lange einen Fallback. Damit wandern Lade- und Fehlerzustände weiter nach außen, statt in jeder Komponente einzeln behandelt zu werden.

Das ersetzt eine Cache-Bibliothek nicht – irgendjemand muss die Promises weiterhin erzeugen, cachen und invalidieren, und genau das übernehmen TanStack Query und SWR inzwischen mit Suspense-Unterstützung. Aber es ändert, wie sich das Warten im Baum anfühlt. Diesem eigenen großen Thema widme ich den Beitrag Suspense und der use-Hook; hier steht es nur als Wegweiser, damit klar ist, wohin die Reise geht.

Was bleiben soll

Wenn von diesem Beitrag ein Satz hängen bleiben soll, dann dieser: Frag bei jedem Wert, der wie Zustand aussieht, zuerst, wo seine Wahrheit lebt. Lebt sie im Browser, ist es echter Client-Zustand, und useState ist richtig. Lebt sie im Backend, ist es ein Cache, und dann braucht es Cache-Regeln – Schlüssel, Alter, Invalidierung – und ein Werkzeug, das diese Buchhaltung übernimmt, statt sie in jeden useEffect zu kopieren.

Diese Unterscheidung zuerst zu treffen und erst danach das Werkzeug zu wählen, erspart mehr Fehler als jede einzelne Bibliothek. Und sie macht die Anwendung kleiner, weil das meiste, was man für Zustand hielt, sich als geliehene Kopie entpuppt, die man nur richtig verwalten muss.

Weiterführende Quellen

Kommentare

Kommentar schreiben