alle Artikel
15 Minuten

Fetch like a pro, Teil 1: Mit TanStack Query und Server State

Frank Lechner & KI ·
„Server State ist kein klassisches State-Management-Problem — es ist ein Caching-Problem mit Nebenwirkungen wie Race Conditions, Deduplizierung und Hintergrund-Synchronisation. ”
Fetch like a pro, Teil 1: Mit TanStack Query und Server State
Dieser Text wurde mit Hilfe von KI erstellt.

Stell dir vor, du baust die Artikel-Übersicht für ein Blog-System. Klingt nach einer Sache von fünf Minuten: Daten holen, anzeigen, fertig. Und dann schreibst du zum gefühlt hundertsten Mal diesen Code:

function ArticleList() {
  const [articles, setArticles] = useState<Article[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchArticles() {
      try {
        setIsLoading(true);
        const res = await fetch("/api/articles");
        if (!res.ok) throw new Error("Fehler beim Laden");
        const data = await res.json();
        if (!cancelled) setArticles(data);
      } catch (err) {
        if (!cancelled) setError(err as Error);
      } finally {
        if (!cancelled) setIsLoading(false);
      }
    }

    fetchArticles();
    return () => {
      cancelled = true;
    };
  }, []);

  // ...
}

LIVE CODE

Zwölf Zeilen für einen simplen GET-Request. Und das Schlimmste: Es ist noch nicht mal vollständig. Kein Caching, kein Refetch bei Fokuswechsel, keine Deduplizierung, wenn zwei Komponenten dieselben Daten brauchen. Sobald du eine zweite Komponente hast, die auch Artikel lädt, fängst du wieder bei null an — oder baust dir einen eigenen, halbgaren Cache.

Das Problem liegt nicht an dir. Es liegt daran, dass wir versuchen, mit Werkzeugen für Client State ein Problem zu lösen, das eigentlich Server State ist. Und das sind zwei fundamental unterschiedliche Dinge.

Client State vs. Server State

Client State

Server State

Eigentümer

Deine App

Ein anderes System (Backend, DB)

Synchron?

Ja, sofort verfügbar

Nein, asynchron und potenziell veraltet

Beispiele

Formularfelder, UI-Toggles, Theme

Artikel-Liste, Nutzerprofil, Kommentare

Kann "von außen" geändert werden?

Nein

Ja, jederzeit von anderen Clients

Braucht Caching?

Selten

Fast immer

Typisches Tool

useState, useReducer, Zustand, Redux

TanStack Query, SWR, RTK Query

Der entscheidende Unterschied: Server State gehört dir nicht. Während du auf deinem Bildschirm die Artikel-Liste anschaust, kann jemand anders im Backend einen neuen Artikel veröffentlichen, einen Kommentar löschen oder einen Tippfehler korrigieren. Dein lokaler State weiß davon nichts — er ist immer nur eine Momentaufnahme, die mit der Zeit veraltet (stale wird).

Klassische State-Management-Tools wie Redux oder Zustand sind für synchronen, dir gehörenden State gebaut. Sie wissen nichts von Caching-Strategien, Revalidierung oder Race Conditions. Genau hier setzt TanStack Query an.

Was TanStack Query anders macht

TanStack Query (früher React Query) ist kein globaler State Manager im klassischen Sinn. Es ist ein spezialisierter Cache für asynchrone Daten, der die typischen Server-State-Probleme direkt mitlöst:

  • Caching: Einmal geladene Daten werden zwischengespeichert und bei Bedarf sofort angezeigt, während im Hintergrund neu geladen wird.

  • Deduplizierung: Fragen zwei Komponenten gleichzeitig dieselben Daten an, wird nur ein Request geschickt.

  • Automatisches Refetching: Bei Fenster-Fokus, Reconnect oder nach einem definierten Intervall.

  • Request-Status als Nebenprodukt: isPending, isError, data — ohne dass du das selbst verdrahten musst.

Drei Kernkonzepte solltest du dir vor dem ersten Codebeispiel merken:

  1. Query Key: Ein eindeutiger Identifier für einen Datensatz im Cache, z. B. ['articles'] oder ['article', articleId].

  2. Query Function: Eine async Funktion, die die eigentlichen Daten holt — meist ein simpler fetch-Call.

  3. Cache-Lifecycle: Daten durchlaufen die Zustände fresh → stale → inactive → garbage collected. Solange Daten fresh sind, wird nicht neu geladen. Werden sie stale, triggert TanStack Query bei nächster Gelegenheit ein Refetch im Hintergrund.

Setup & erste Query

Zuerst der Provider, meist einmal ganz oben in der App:

// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ArticleList />
    </QueryClientProvider>
  );
}

Und jetzt die Artikel-Liste, diesmal mit TanStack Query:

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

interface Article {
  id: string;
  title: string;
  excerpt: string;
  publishedAt: string;
}

async function fetchArticles(): Promise<Article[]> {
  const res = await fetch("/api/articles");
  if (!res.ok) throw new Error("Fehler beim Laden der Artikel");
  return res.json();
}

function ArticleList() {
  const { data: articles, isPending, isError, error } = useQuery({
    queryKey: ["articles"],
    queryFn: fetchArticles,
  });

  if (isPending) return <p>Lade Artikel...</p>;
  if (isError) return <p>Fehler: {error.message}</p>;

  return (
    <ul>
      {articles.map((article) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

LIVE CODE

Aus zwölf Zeilen Boilerplate wurden vier Zeilen Query-Definition. Kein useEffect, kein manuelles Cancel-Handling, kein selbstgebauter Loading-State — und trotzdem bekommst du automatisches Caching, Deduplizierung und Refetching gratis dazu.

Hinweis zu v5: Seit TanStack Query v5 heißt der Ladezustand isPending statt isLoading, und useQuery nimmt ausschließlich ein Options-Objekt entgegen (kein useQuery(key, fn) mehr).

Wie sich die UI bei Cache-Änderungen aktualisiert

Ein Detail, das im ersten Beispiel leicht untergeht, aber für alles Weitere entscheidend ist: useQuery liefert nicht einfach einmalig Daten, sondern abonniert die Komponente auf einen bestimmten Cache-Eintrag. Ändert sich dieser Eintrag — aus welchem Grund auch immer —, werden automatisch alle Komponenten neu gerendert, die auf denselben Query Key hören. Kein Context, kein Prop-Drilling, kein manuelles "Bescheid geben" zwischen Komponenten nötig.

Das lässt sich gut an zwei unabhängigen Komponenten zeigen, die beide denselben Query Key verwenden:

function ArticleCount() {
  const { data: articles } = useQuery({
    queryKey: ["articles"],
    queryFn: fetchArticles,
  });

  return <span>{articles?.length ?? 0} Artikel</span>;
}

function ArticleList() {
  const { data: articles } = useQuery({
    queryKey: ["articles"],
    queryFn: fetchArticles,
  });

  return (
    <ul>
      {articles?.map((article) => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

LIVE CODE

Beide Komponenten rufen useQuery mit dem Key ["articles"] auf. TanStack Query erkennt das und behandelt beide Aufrufe als Abonnenten desselben Cache-Eintrags — es wird nur ein Request geschickt, und beide Komponenten sehen exakt dieselben Daten. Wird der Cache-Eintrag später aktualisiert, etwa weil ArticleList an anderer Stelle einen neuen Artikel anlegt, rendert ArticleCount automatisch mit dem neuen Wert nach — obwohl die beiden Komponenten nichts voneinander wissen und nicht einmal in derselben Elternkomponente hängen müssen.

Intern funktioniert das über ein Observer-Pattern: Der QueryClient hält den eigentlichen Cache, und jede useQuery-Instanz registriert sich als Listener für ihren Key. Ändert sich der Eintrag, benachrichtigt der Cache alle Listener, React re-rendert die betroffenen Komponenten — fertig.

Für die eigentliche Änderung des Cache-Eintrags gibt es zwei grundsätzlich unterschiedliche Wege, die dir in den folgenden Abschnitten immer wieder begegnen:

setQueryData

invalidateQueries

Wirkung

Schreibt sofort und synchron neue Daten in den Cache

Markiert den Eintrag als stale

Netzwerk-Request?

Nein

Ja, im Hintergrund (oder sofort, je nach Kontext)

UI-Update

Sofort, noch im selben Tick

Sobald der Refetch abgeschlossen ist

Typischer Einsatz

Optimistic Updates, direktes Schreiben bekannter Werte

Nach Mutations, wenn der Server die "Wahrheit" hat

Der Unterschied ist wichtig: setQueryData sagt "ich weiß bereits, wie die neuen Daten aussehen, trag sie direkt ein" — die UI reagiert augenblicklich, ganz ohne Serverkontakt. invalidateQueries sagt dagegen "diese Daten könnten veraltet sein, hol dir eine frische Version" — die UI zeigt so lange die alten (jetzt als stale markierten) Daten, bis der Hintergrund-Refetch abgeschlossen ist, und aktualisiert sich dann automatisch über denselben Subscription-Mechanismus.

Genau dieses Zusammenspiel — Cache ändert sich, alle Abonnenten werden benachrichtigt — ist der Grund, warum das Mutation-Beispiel weiter unten und das Optimistic-Update-Beispiel in Teil 2 dieser Artikelserie ohne jeglichen manuellen State-Abgleich zwischen Komponenten auskommen.

staleTime vs. gcTime

Diese beiden Optionen sind die am häufigsten verwechselten in ganz TanStack Query — und das zu Recht, denn sie klingen ähnlich, beantworten aber zwei völlig unterschiedliche Fragen:

  • staleTime beantwortet: "Sind die Daten, die ich gerade im Cache habe, noch gut genug, um sie ohne neuen Request anzuzeigen?" Solange Daten fresh sind, verwendet TanStack Query sie einfach weiter, ganz ohne Netzwerk-Request — auch wenn die Komponente neu mountet oder das Fenster den Fokus zurückbekommt. Standard: 0, also gelten Daten sofort nach dem Laden als stale.

  • gcTime (früher cacheTime) beantwortet eine ganz andere Frage: "Wie lange darf ein Cache-Eintrag im Speicher bleiben, wenn ihn gerade niemand mehr braucht?" Sobald die letzte Komponente, die eine Query nutzt, unmountet, wird der Eintrag inactive — und nach gcTime (Standard: 5 Minuten) endgültig aus dem Speicher entfernt (garbage collected).

Der Unterschied lässt sich am besten an einem Ablauf zeigen, wenn ein Nutzer die Artikel-Liste öffnet, wegnavigiert und zurückkommt:

Zeitpunkt

Zustand

Was passiert

Query lädt zum ersten Mal

fresh

Request läuft, danach Daten im Cache

Nach staleTime (z. B. 60s)

stale

Daten bleiben im Cache und werden weiter angezeigt — aber beim nächsten Trigger (Remount, Fokus) folgt ein Hintergrund-Refetch

Komponente unmountet

inactive

Niemand nutzt die Query gerade, sie bleibt aber im Cache

Nach gcTime ohne erneute Nutzung

entfernt

Cache-Eintrag wird komplett gelöscht

Wichtig dabei: Ein stale Eintrag verschwindet nicht — er wird nur beim nächsten Anlass im Hintergrund aktualisiert, während die alten (jetzt veralteten) Daten weiterhin sofort angezeigt werden. Kein Loading-Spinner, keine leere Seite. Erst wenn ein Eintrag inactive wird und gcTime verstreicht, ist er wirklich weg — und ein erneuter Aufruf lädt dann komplett neu, inklusive Loading-Zustand.

Die praktische Konsequenz: staleTime steuert, wie oft überhaupt Requests rausgehen — das ist der Hebel, den du meistens anfassen willst. gcTime steuert nur, wie lange ein ungenutzter Eintrag im Hintergrund "warmgehalten" wird, für den Fall, dass der Nutzer kurz danach zurückkommt. gcTime sollte praktisch immer größer oder gleich staleTime sein — ein Eintrag, der schon aus dem Speicher entfernt wurde, bevor er überhaupt stale werden konnte, ergibt keinen Sinn.

Für unsere Artikel-Liste, die sich nicht sekündlich ändert, ergibt ein höheres staleTime Sinn:

function useArticles(filters: ArticleFilters) {
  return useQuery({
    queryKey: ["articles", filters],
    queryFn: () => fetchArticles(filters),
    staleTime: 60 * 1000, // 1 Minute lang "frisch", kein unnötiges Refetch
    gcTime: 10 * 60 * 1000 // 10 Minuten lang, statt 5 Minuten standard
  });
}

Faustregel: Je seltener sich Daten ändern und je teurer der Request ist, desto höher darf staleTime sein. gcTime lohnt sich vor allem dann anzupassen, wenn Nutzer typischerweise zwischen bestimmten Ansichten hin- und herspringen (z. B. Artikel-Liste ↔ Artikel-Detail) — ein etwas höherer Wert als der Standard hält die Daten für diesen Fall länger griffbereit, ohne den Speicher unbegrenzt wachsen zu lassen.

Query Keys im Detail

Query Keys sind mehr als nur Namen — sie sind der Schlüssel (im wörtlichen Sinn), über den TanStack Query Caching und Invalidierung steuert. Zwei Queries mit demselben Key teilen sich denselben Cache-Eintrag.

Für unsere Blog-API brauchen wir bald mehr als nur ['articles'] — etwa gefilterte und paginierte Listen:

interface ArticleFilters {
  category?: string;
  page: number;
}

function useArticles(filters: ArticleFilters) {
  return useQuery({
    queryKey: ["articles", filters],
    queryFn: () => fetchArticles(filters),
  });
}

Wichtig: TanStack Query vergleicht Keys tief (deep equality), nicht per Referenz. ['articles', { page: 1 }] und ['articles', { page: 1 }] gelten als derselbe Key, auch wenn es zwei unterschiedliche Objekte sind. Das erlaubt dir, Parameter einfach als Teil des Keys mitzugeben, ohne dir über Objektidentität Gedanken zu machen.

Häufiger Fehler: Query Keys zu grob wählen, z. B. immer nur ['articles'], egal welche Filter aktiv sind. Dann zeigt TanStack Query beim Filterwechsel kurzzeitig veraltete Daten aus dem falschen Kontext an, weil alle Anfragen denselben Cache-Eintrag teilen. Die Faustregel: Alles, wovon die Query-Function abhängt, gehört in den Key.

Einzelnen Artikel laden: Dependent Queries

Für die Detailseite eines Artikels brauchen wir zwei Dinge: den Artikel selbst und die zugehörigen Kommentare. Die Kommentare können aber erst geladen werden, wenn wir die Artikel-ID kennen — typischerweise, nachdem der Artikel selbst geladen wurde (z. B. weil die ID erst aus der Artikel-Antwort validiert wird, oder weil wir den Kommentar-Endpunkt erst nach erfolgreichem Artikel-Load anfragen wollen).

function useArticle(articleId: string) {
  return useQuery({
    queryKey: ["article", articleId],
    queryFn: () => fetchArticle(articleId),
  });
}

function useComments(articleId: string, enabled: boolean) {
  return useQuery({
    queryKey: ["comments", articleId],
    queryFn: () => fetchComments(articleId),
    enabled, // Query startet erst, wenn "enabled" true ist
  });
}

function ArticlePage({ articleId }: { articleId: string }) {
  const { data: article, isSuccess } = useArticle(articleId);
  const { data: comments } = useComments(articleId, isSuccess);

  // ...
}

Das enabled-Flag ist der Schlüssel für Dependent Queries: Solange enabled false ist, führt TanStack Query die Query-Function gar nicht erst aus. Sobald die Bedingung erfüllt ist, startet die Anfrage automatisch — kein manuelles Verketten von Promises nötig.

Mutations: Artikel erstellen und bearbeiten

Für schreibende Operationen — einen neuen Artikel veröffentlichen, einen bestehenden bearbeiten — gibt es useMutation:

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

interface NewArticle {
  title: string;
  content: string;
  category: string;
}

async function createArticle(newArticle: NewArticle): Promise<Article> {
  const res = await fetch("/api/articles", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(newArticle),
  });
  if (!res.ok) throw new Error("Artikel konnte nicht erstellt werden");
  return res.json();
}

function useCreateArticle() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createArticle,
    onSuccess: () => {
      // Cache für die Artikel-Liste invalidieren, damit neu geladen wird
      queryClient.invalidateQueries({ queryKey: ["articles"] });
    },
  });
}

function NewArticleForm() {
  const { mutate, isPending } = useCreateArticle();

  function handleSubmit(data: NewArticle) {
    mutate(data);
  }

  // ...
}

Das Pattern mutationFn + onSuccess mit invalidateQueries ist der Standardweg, um nach einer Änderung den Cache wieder in einen konsistenten Zustand zu bringen. TanStack Query markiert die betroffenen Queries als stale und lädt sie beim nächsten Bedarf automatisch neu.

Häufiger Fehler: Nach einer Mutation den State manuell zusammenzubasteln (setArticles([...articles, newArticle])), statt den Cache zu invalidieren. Das führt schnell zu Inkonsistenzen, sobald Server und Client unterschiedliche Annahmen treffen — etwa bei serverseitig generierten Feldern wie id oder publishedAt.

Häufige Fehler im Überblick

  • useEffect und useQuery kombinieren, um "noch etwas nachzuladen" — meist ein Zeichen dafür, dass eine Dependent Query mit enabled die sauberere Lösung wäre.

  • Query Keys zu instabil bauen, z. B. mit einem neuen Objekt oder einer Inline-Funktion pro Render, ohne dass sich der eigentliche Inhalt ändert — führt zu unnötigen Refetches.

  • Mutations ohne Invalidierung, wodurch die UI nach einer Änderung einen veralteten Stand zeigt, bis der Nutzer manuell neu lädt.

  • Fehlendes Error Handling auf Komponentenebene — isError und error werden bereitgestellt, aber oft schlicht ignoriert.

  • staleTime: 0 überall, obwohl viele Daten (Kategorien, Autoren-Profile, Tags) sich selten ändern und von einem höheren Wert profitieren würden.

Fazit

Server State ist kein klassisches State-Management-Problem — es ist ein Caching-Problem mit Nebenwirkungen wie Race Conditions, Deduplizierung und Hintergrund-Synchronisation. Sobald du diesen Unterschied verinnerlicht hast, wird klar, warum Tools wie Redux oder Zustand dafür nie richtig gepasst haben und warum TanStack Query so viel Boilerplate einspart.

Die Grundlagen — useQuery, useMutation, Query Keys — bringen dich schnell von "funktioniert" zu "funktioniert robust". Die Pro-Patterns — Prefetching, Optimistic Updates, Infinite Queries, select — sind der Unterschied zwischen einer App, die einfach nur Daten anzeigt, und einer, die sich schnell und durchdacht anfühlt.

Der beste nächste Schritt: Nimm eine bestehende useEffect-Fetch-Logik in deinem eigenen Projekt und ersetze sie Schritt für Schritt durch useQuery. Du wirst überrascht sein, wie viel Code du dabei streichen kannst.

Was hier noch fehlt: Prefetching, Optimistic Updates, Infinite Queries und weitere Patterns, die aus einer soliden Lösung eine wirklich durchdachte machen. Genau darum geht's im nächsten Teil dieser Reihe: Fetch like a pro, Teil 2 — Pro-Patterns für den Alltag.