Fetch like a pro, Teil 3: TanStack Query in Produktion
)
Im ersten Teil dieser Serie ging es um die Mechanik: useQuery, useMutation, Query Keys, Caching-Strategien. Genug, um eine Demo-App zu bauen, die sich rund anfühlt. Aber sobald echte Nutzer, echte Logins und ein echtes Deployment ins Spiel kommen, tauchen Fragen auf, die in der Demo nie relevant waren:
Wie bekommt jede Query ihren Auth-Header, ohne dass du das in jeder queryFn wiederholst? Was passiert, wenn ein Token abläuft, während der Nutzer gerade etwas tut? Wie sorgst du dafür, dass die Artikel-Seite beim ersten Laden nicht leer aufblitzt, bevor die Daten da sind? Und wie hältst du zehn, zwanzig, fünfzig Queries im Code eigentlich sauber auseinander, ohne dass Query Keys zu einem Ratespiel werden?
Genau diese Fragen sind der Stoff für Teil 2.
Authentifizierung & Token-Refresh
Der naive Ansatz ist, den Auth-Header in jeder queryFn einzeln zu setzen:
async function fetchArticles(): Promise<Article[]> {
const token = getToken(); // woher auch immer
const res = await fetch("/api/articles", {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Fehler beim Laden");
return res.json();
}Das funktioniert, bis du die zehnte queryFn schreibst und dieselben drei Zeilen zum zehnten Mal kopierst. Besser: ein zentraler httpClient, der Auth-Header, Base-URL und Fehlerbehandlung an einer Stelle bündelt:
// lib/httpClient.ts
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function httpClient<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const res = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!res.ok) {
throw new HttpError(res.status, `Request fehlgeschlagen: ${res.status}`);
}
return res.json();
}
export { httpClient, HttpError };Jede queryFn wird dadurch auf eine Zeile reduziert, und Auth-Logik ist an genau einer Stelle geändert, wenn sich mal was ändert.
Der eigentlich knifflige Teil ist der Token-Refresh. Läuft ein Access-Token während einer Session ab, soll der Nutzer davon möglichst nichts merken — kein Rauswurf, kein Fehler-Toast, wenn ein Refresh-Token noch gültig ist. Das lässt sich sauber in den httpClient einbauen:
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
// Mehrere gleichzeitig fehlschlagende Requests sollen nur EINEN
// Refresh auslösen, nicht fünf parallele.
if (!refreshPromise) {
refreshPromise = fetch("/api/auth/refresh", { method: "POST" })
.then((res) => {
if (!res.ok) throw new Error("Refresh fehlgeschlagen");
return res.json();
})
.then((data) => data.accessToken)
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
async function httpClient<T>(path: string, options: RequestInit = {}): Promise<T> {
const makeRequest = (token: string | null) =>
fetch(`${API_BASE_URL}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
let res = await makeRequest(getToken());
if (res.status === 401) {
const newToken = await refreshAccessToken();
setToken(newToken);
res = await makeRequest(newToken); // einmal wiederholen, nicht mehr
}
if (!res.ok) {
throw new HttpError(res.status, `Request fehlgeschlagen: ${res.status}`);
}
return res.json();
}Wichtig ist die refreshPromise-Deduplizierung: Feuern beim Tab-Wechsel plötzlich fünf Queries gleichzeitig einen 401, soll trotzdem nur ein Refresh-Request rausgehen, an dessen Ergebnis sich alle fünf anhängen — nicht fünf parallele Refresh-Versuche, die sich gegenseitig invalidieren.
Auf der TanStack-Query-Seite selbst musst du dafür nichts Besonderes tun — der httpClient kümmert sich um den Refresh, TanStack Query sieht nur einen normalen, letztlich erfolgreichen Request. Einzig bei einem tatsächlich abgelaufenen Refresh-Token (Logout-Fall) willst du global reagieren — dafür eignet sich die globale Fehlerbehandlung aus Teil 1 (QueryCache.onError), um bei einem bestimmten Fehlercode zur Login-Seite umzuleiten.
Ein Wort zur Token-Speicherung: Die Beispiele oben lassen bewusst offen, wo getToken()/setToken() den Token eigentlich ablegen — und genau das ist eine sicherheitsrelevante Entscheidung, keine Implementierungsdetail-Frage. Zwei gängige Optionen mit unterschiedlichen Trade-offs:
localStorageist der bequemste Weg und der, den die meisten Tutorials zeigen — aber per JavaScript auslesbar. Gelingt einem Angreifer XSS (z. B. über ungefilterten Artikel-Content), kann er den Token direkt abgreifen.httpOnly-Cookies sind für JavaScript unsichtbar, derhttpClientmuss den Token dann gar nicht selbst anhängen — der Browser schickt das Cookie automatisch mit. Dafür brauchst du serverseitigSameSite- undSecure-Flags sowie einen CSRF-Schutz für Mutations, weil Cookies im Gegensatz zulocalStorageauch von anderen Origins mitgeschickt werden können.
Für die meisten Projekte mit sensiblen Nutzerdaten ist die httpOnly-Cookie-Variante die robustere Wahl, auch wenn sie mehr Server-seitige Vorarbeit braucht. localStorage ist vertretbar, wenn XSS durch konsequentes Sanitizing von Nutzer-generiertem Content (siehe Artikel-Beispiel oben — Rich-Text-Content gehört nie ungefiltert per dangerouslySetInnerHTML gerendert) bereits gut abgedeckt ist.
SSR & Hydration
Bei serverseitig gerenderten Apps (Next.js App Router als Beispiel) willst du vermeiden, dass die Artikel-Liste beim ersten Laden erst einen Spinner zeigt und dann nachlädt — die Daten sollen im initialen HTML schon da sein. TanStack Query unterstützt das über Prefetching auf dem Server plus Hydration auf dem Client.
// app/articles/page.tsx (Server Component)
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { ArticleList } from "./ArticleList";
import { articleKeys } from "./queryKeys";
import { fetchArticles } from "./articles.api";
export default async function ArticlesPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: articleKeys.list({ page: 1 }),
queryFn: () => fetchArticles({ page: 1 }),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ArticleList />
</HydrationBoundary>
);
}// app/articles/ArticleList.tsx (Client Component)
"use client";
import { useQuery } from "@tanstack/react-query";
import { articleKeys } from "./queryKeys";
import { fetchArticles } from "./articles.api";
export function ArticleList() {
// Gleicher Query Key wie im Prefetch → useQuery findet die
// bereits gehydrateten Daten im Cache und muss nicht neu laden.
const { data: articles } = useQuery({
queryKey: articleKeys.list({ page: 1 }),
queryFn: () => fetchArticles({ page: 1 }),
});
return (
<ul>
{articles?.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
);
}Der entscheidende Mechanismus: dehydrate(queryClient) serialisiert den Server-seitigen Cache-Zustand, HydrationBoundary spielt ihn auf dem Client wieder ein, bevor React überhaupt rendert. useQuery im Client sieht dadurch von Anfang an Daten im Cache — kein Loading-Zustand, kein Layout-Sprung.
Worauf man achten sollte: Der Query Key im Server-Prefetch und im Client-useQuery muss exakt übereinstimmen (inklusive aller Filter-Parameter) — sonst landen die gehydrateten Daten unter einem anderen Cache-Eintrag, als der die Komponente abfragt, und du bekommst trotzdem einen Loading-Flash. Eine zentrale Query-Key-Factory (siehe unten) verhindert genau dieses Problem.
Cache-Persistenz über Reloads hinweg
Standardmäßig ist der TanStack-Query-Cache reiner In-Memory-State — ein Reload, und alles ist weg, jede Query lädt neu. Für Szenarien mit häufigen Reloads oder Offline-Nutzung lohnt sich persistQueryClient, das den Cache z. B. in localStorage sichert:
npm install @tanstack/query-persist-client-core @tanstack/query-sync-storage-persisterimport { QueryClient } from "@tanstack/react-query";
import { persistQueryClient } from "@tanstack/query-persist-client-core";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24h, muss ≥ maxAge des Persisters sein
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24h
});Beim nächsten Seitenaufruf lädt TanStack Query den gesicherten Cache-Stand, bevor überhaupt ein Request rausgeht — die Artikel-Liste erscheint sofort, während im Hintergrund geprüft wird, ob die Daten noch aktuell sind (abhängig von staleTime).
Wichtige Einschränkung: Nicht jede Query sollte persistiert werden. Sensible Daten (z. B. Zahlungsinformationen) oder Daten, die sich pro Sekunde ändern, gehören nicht in localStorage. Über die Option dehydrateOptions.shouldDehydrateQuery lässt sich das gezielt einschränken:
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Nur "articles"-Queries persistieren, alles andere bleibt In-Memory
return query.queryKey[0] === "articles";
},
},
});Der Sicherheitsaspekt dahinter: Alles, was persistQueryClient in localStorage schreibt, liegt unverschlüsselt und für jedes im Browser laufende Script auslesbar — genau dieselbe Angriffsfläche wie bei der Token-Speicherung im Auth-Abschnitt. Für öffentliche Inhalte wie Artikel-Listen ist das unkritisch. Bei allem mit Personenbezug — Nutzerprofil, Bestellhistorie, private Nachrichten — sollte shouldDehydrateQuery diese Query Keys explizit ausschließen, statt sie versehentlich über den Standard-Filter mit zu persistieren. Eine einfache Leitfrage hilft hier: Würdest du diese Daten auch unverschlüsselt in eine Cookie-Datei schreiben? Wenn nein, gehören sie auch nicht in den persistierten Query-Cache.
Serverlast & Performance
Wie viele Requests eine App tatsächlich auslöst, hängt stark davon ab, wie die Queries konfiguriert sind. Ein zu niedriges staleTime (oder der Standard 0) sorgt dafür, dass TanStack Query bei jedem Fokuswechsel oder Mount neu lädt, selbst wenn sich die Daten seit Sekunden nicht geändert haben:
function useArticles(filters: ArticleFilters) {
return useQuery({
queryKey: articleKeys.list(filters),
queryFn: () => fetchArticles(filters),
staleTime: 60 * 1000, // verhindert wiederholtes Neuladen bei kurz aufeinanderfolgenden Aufrufen
refetchOnWindowFocus: false, // teurer Endpoint: kein Refetch bei jedem Tab-Wechsel
});
}Bei teuren oder aufwendig berechneten Endpunkten lohnt sich, refetchOnWindowFocus gezielt zu deaktivieren — Nutzer, die häufig zwischen Tabs wechseln, würden sonst unnötig viele Requests auslösen. Bei Suchfeldern verhindert Debouncing der Query selbst, dass bei jedem Tastenanschlag ein Request rausgeht:
function useArticleSearch(query: string) {
const debouncedQuery = useDebouncedValue(query, 300);
return useQuery({
queryKey: ["articles", "search", debouncedQuery],
queryFn: () => searchArticles(debouncedQuery),
enabled: debouncedQuery.length > 2,
});
}Wichtig für die Einordnung: Das ist Effizienz- und UX-Optimierung, kein Schutzmechanismus. Ein Client, der gezielt Last erzeugen will, hält sich nicht an staleTime oder Debouncing — echtes Rate Limiting (z. B. pro IP oder Token) muss serverseitig passieren. Die hier gezeigten Einstellungen sorgen dafür, dass normale Nutzung nicht unnötig viele Requests erzeugt, nicht dafür, dass böswillige Nutzung verhindert wird.
Software-Design: Ordnerstruktur & Query Key Factories
Je mehr Queries ein Projekt hat, desto wichtiger wird, wo der ganze Code eigentlich lebt. Der klassische Reflex — ein Ordner hooks/, ein Ordner api/, quer durchs Projekt verteilt — funktioniert bei fünf Queries, wird aber bei fünfzig zur Sucherei. Robuster ist eine Feature-basierte Struktur:
src/
├── features/
│ ├── articles/
│ │ ├── api/
│ │ │ └── articles.api.ts // reine fetch-Funktionen, kein React
│ │ ├── hooks/
│ │ │ ├── useArticles.ts // useQuery-Wrapper
│ │ │ ├── useArticle.ts
│ │ │ └── useCreateArticle.ts // useMutation-Wrapper
│ │ ├── queryKeys.ts // Key-Factory für dieses Feature
│ │ ├── types.ts
│ │ └── components/
│ │ ├── ArticleList.tsx
│ │ └── ArticleCard.tsx
│ └── comments/
│ └── ...
├── lib/
│ ├── queryClient.ts // zentrale QueryClient-Instanz
│ ├── httpClient.ts // Auth-Header, Base-URL, Fehler-Parsing
│ └── errors.ts
└── app/Die Aufteilung folgt einer klaren Regel: api/-Dateien kennen kein React, hooks/-Dateien sind die einzige Schicht, die useQuery/useMutation anfasst, Komponenten importieren nie direkt aus api/. Das hält TanStack-Query-spezifischen Code an einer Stelle austauschbar und macht api/-Funktionen isoliert testbar, ohne Provider-Setup.
Query Key Factories lösen ein zweites, verwandtes Problem: lose Key-Strings, die über die Codebase verstreut sind und bei jedem Tippfehler einen neuen, ungewollten Cache-Eintrag erzeugen.
// features/articles/queryKeys.ts
export const articleKeys = {
all: ["articles"] as const,
lists: () => [...articleKeys.all, "list"] as const,
list: (filters: ArticleFilters) => [...articleKeys.lists(), filters] as const,
details: () => [...articleKeys.all, "detail"] as const,
detail: (id: string) => [...articleKeys.details(), id] as const,
};Der Vorteil zeigt sich bei der Invalidierung: queryClient.invalidateQueries({ queryKey: articleKeys.all }) trifft automatisch alle Artikel-Queries — Listen wie Details —, ohne dass jeder einzelne Key von Hand aufgezählt werden muss. Und weil die Factory typisiert ist, verhindert Autocomplete Tippfehler, die sonst erst zur Laufzeit als "warum lädt das nicht neu?" auffallen.
Kurzer Ausblick: Was noch dazugehört
Drei weitere Themen verdienen an dieser Stelle zumindest eine Erwähnung, auch wenn sie den Rahmen dieses Posts sprengen würden:
Echtzeit-Daten (WebSockets/SSE): Statt an TanStack Query vorbei einen zweiten, parallelen State für Live-Updates zu pflegen, lässt sich ein WebSocket-Event direkt in den bestehenden Cache schreiben — per
queryClient.setQueryData()bei einem punktuellen Update, oderinvalidateQueries(), wenn der Server ohnehin die "Wahrheit" liefert.Validierung mit Zod: "Es kompiliert" heißt nicht "die Server-Antwort stimmt mit dem TypeScript-Typ überein". Ein
articleSchema.parse(data)direkt in derqueryFnsorgt dafür, dass eine unerwartete API-Änderung sofort als Fehler auffällt, statt stillundefined-Zugriffe weiter unten in der Komponente zu verursachen.Testing: Mit MSW (Mock Service Worker) lassen sich Requests auf Netzwerk-Ebene abfangen, statt
fetchselbst zu mocken — dadurch bleibenqueryFn-Implementierungen unverändert testbar. Pro Test empfiehlt sich ein frischerQueryClient, damit sich Cache-Zustände zwischen Tests nicht überlagern.CSRF-Schutz & XSS-Sanitizing im Detail: Die obigen Abschnitte streifen beides (CSRF bei
httpOnly-Cookies, Sanitizing bei Rich-Text-Content), gehen aber nicht in die konkrete Implementierung — das wäre eher etwas für einen eigenen, sicherheitsfokussierten Post als für einen Fetch-Post.
Jedes dieser Themen wäre für sich genommen einen eigenen Post wert — falls Interesse besteht, ließe sich das als Teil 3 fortsetzen.
Fazit
Der Sprung von Teil 1 zu Teil 2 ist im Kern der Sprung von "funktioniert in der Demo" zu "trägt in Produktion". Auth-Header und Token-Refresh gehören an eine zentrale Stelle, nicht in jede queryFn einzeln. SSR-Prefetching mit HydrationBoundary verhindert den Loading-Flash, den Nutzer als "langsam" wahrnehmen, auch wenn die Daten technisch schnell da sind. Cache-Persistenz macht aus wiederholten Ladezeiten ein einmaliges Ereignis — vorausgesetzt, man behält im Blick, was davon überhaupt unverschlüsselt im Browser liegen darf. Gezielte staleTime- und refetchOnWindowFocus-Konfiguration hält die Zahl der Requests im normalen Betrieb niedrig. Und eine Feature-basierte Ordnerstruktur mit Query-Key-Factories ist der Unterschied zwischen einer Codebase, die bei 10 Queries übersichtlich bleibt, und einer, die es bei 50 nicht mehr tut.
Keines dieser Themen ist kompliziert für sich genommen — aber zusammen sind sie genau das, was zwischen einem guten Tutorial-Beispiel und einer Codebase liegt, die auch in einem Jahr noch wartbar ist.