Fetch like a pro, Teil 1: Mit TanStack Query und Server State
)
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;
};
}, []);
// ...
}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 |
| 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:
Query Key: Ein eindeutiger Identifier für einen Datensatz im Cache, z. B.
['articles']oder['article', articleId].Query Function: Eine async Funktion, die die eigentlichen Daten holt — meist ein simpler
fetch-Call.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>
);
}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
isPendingstattisLoading, unduseQuerynimmt ausschließlich ein Options-Objekt entgegen (keinuseQuery(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>
);
}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:
|
| |
|---|---|---|
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:
staleTimebeantwortet: "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ühercacheTime) 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 nachgcTime(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 |
| Request läuft, danach Daten im Cache |
Nach |
| Daten bleiben im Cache und werden weiter angezeigt — aber beim nächsten Trigger (Remount, Fokus) folgt ein Hintergrund-Refetch |
Komponente unmountet |
| Niemand nutzt die Query gerade, sie bleibt aber im Cache |
Nach | 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
useEffectunduseQuerykombinieren, um "noch etwas nachzuladen" — meist ein Zeichen dafür, dass eine Dependent Query mitenableddie 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 —
isErrorunderrorwerden 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.