Blog

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

Serverdaten sind Zustand, aber nicht client-eigener Zustand. Warum diese Unterscheidung wichtig ist, wann useEffect zum schlechten Cache wird und was eine Server-State-Bibliothek übernimmt.

≈ 9 Min. Lesezeit

Diesen Beitrag anhören (12 Min.)

MP3 herunterladen
const [invoices, setInvoices] = useState([]);

useEffect(() => {
  fetch("/api/invoices")
    .then((r) => r.json())
    .then(setInvoices);
}, []);

invoices ist kein client-eigener Zustand. Es ist eine Cache-Kopie von Daten, deren Original in einer Datenbank hinter /api/invoices liegt. Und diesem Cache fehlt alles, was einen brauchbaren Cache ausmacht: ein Schlüssel, unter dem eine zweite Komponente dieselbe Kopie findet; ein Alter, ab dem sie als überholt gilt; eine Stelle, an der man sie nach einer Änderung für ungültig erklärt. Solange nur eine Komponente lädt und niemand etwas ändert, fällt das nicht auf.

Der React-Überblick unterscheidet vier Orte für Zustand: die Komponente, die URL, einen globalen 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.

Diese Verwechslung ist kein Anfängerfehler, den man sich abgewöhnt. Ich sehe sie in Code-Reviews 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 liegt die Wahrheit? Bei echtem Client-Zustand liegt 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 liegt im Backend, in einer Datenbank, hinter einer API. Was der Browser davon hat, 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 allein keinen Server-Cache ersetzt

Der übliche erste Reflex sieht so aus: Man nimmt useState für die Daten, useEffect für das Laden, und fertig. Hier noch einmal das Muster vom Anfang, diesmal mit Fehlerpfad und 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 so ähnlich in einem echten Projekt erlebt habe: 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 die aktiven davon 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. Verwenden Zähler und Liste dieselbe Schlüsselfamilie, lassen sie sich mit dieser einen Invalidierung gemeinsam aktualisieren. Das beseitigt eine häufige Ursache für auseinanderlaufende Anzeigen; eine Garantie ersetzt es nicht, denn Schlüssel, Fehlerbehandlung und Serverantworten müssen weiterhin zusammenpassen.

Wer will, geht einen Schritt weiter und aktualisiert die Oberfläche schon, bevor der Server geantwortet hat. Über den onMutate-Callback 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 schlank, hat eine schmale API und ist 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 Callbacks für optimistische Updates. Der Preis ist etwas mehr Gewicht und ein paar Stellschrauben mehr. Für kleine, überschaubare Fälle reicht SWR; sobald die Server-State-Komplexität zu wachsen droht, zahlt sich TanStack Query aus.

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 auch erfahrene Kollegen regelmäßig. Wenn man die Serverdaten aus der Zustandsverwaltung herauszieht, bleibt erschreckend wenig übrig. Die Gegenprobe ist schnell gemacht: alles auflisten, was bisher für „globalen Zustand“ gehalten wurde, und dann Punkt für Punkt streichen. 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 client-eigener Zustand übrig bleibt, ist in vielen Anwendungen ein kleiner Kern: der Text in einem Formular, während man noch tippt, UI-Details wie aufgeklappte Bereiche und einige app-weite Entscheidungen – etwa Theme oder Warenkorb. Dafür reichen häufig useState, Context oder ein leichter Store. Die Serverdaten bleiben ebenfalls Zustand, haben aber eine andere Quelle, Lebensdauer und Invalidierungslogik. Genau deshalb gehören sie nicht ohne Weiteres in denselben globalen Store.

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 use() und Suspense einen weiteren Weg, Serverdaten ins Rendern zu holen: Man reicht einer Komponente ein stabiles Promise, use() suspendiert, bis es erfüllt ist, und eine umschließende <Suspense>-Komponente zeigt so lange einen Fallback. Das Promise sollte aus einem Framework oder Cache stammen und nicht bei jedem Rendern neu erzeugt werden. So rücken Lade- und Fehlerzustände an den Rand, 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 sich React hier entwickelt.

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 liegt. Liegt sie im Browser, ist es echter Client-Zustand, und useState ist richtig. Liegt 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, verhindert doppelte Caches und widersprüchliche Kopien. Serverdaten sind geliehener Zustand: Der Client verwaltet ihre Darstellung, besitzt aber weder ihre Wahrheit noch ihren Lebenszyklus allein.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare