Fetch like a pro, Teil 2: Pro-Patterns für den Alltag
)
Teil 1 dieser Serie hat die Grundlagen abgedeckt: useQuery, useMutation, Query Keys, wie der Cache die UI synchron hält. Genug, um eine Artikel-Liste sauber zu laden und einen neuen Artikel zu erstellen. Aber zwischen "funktioniert" und "fühlt sich richtig gut an" liegt noch ein Stück Weg — genau darum geht's in diesem Post.
Die folgenden Patterns unterscheiden einen soliden von einem wirklich durchdachten Umgang mit Server State. Alle bauen auf demselben Blog/Artikel-Beispiel aus Teil 1 auf.
Prefetching
Wenn du absehen kannst, dass ein Nutzer gleich bestimmte Daten braucht — etwa beim Hover über eine Artikel-Karte —, kannst du die Daten schon vorab in den Cache laden:
function ArticleCard({ articleId }: { articleId: string }) {
const queryClient = useQueryClient();
function handleMouseEnter() {
queryClient.prefetchQuery({
queryKey: ["article", articleId],
queryFn: () => fetchArticle(articleId),
staleTime: 60 * 1000,
});
}
return (
<div onMouseEnter={handleMouseEnter}>
{/* Artikel-Vorschau */}
</div>
);
}Klickt der Nutzer dann tatsächlich auf den Artikel, sind die Daten oft schon da — die Detailseite rendert quasi instant.
Optimistic Updates
Beim Liken eines Artikels willst du nicht warten, bis der Server geantwortet hat, um den Like-Counter hochzuzählen. Stattdessen aktualisierst du den Cache sofort ("optimistisch") und machst den Schritt bei einem Fehler wieder rückgängig:
function useLikeArticle(articleId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => likeArticle(articleId),
onMutate: async () => {
// Laufende Refetches abbrechen, damit sie das optimistische Update nicht überschreiben
await queryClient.cancelQueries({ queryKey: ["article", articleId] });
// Bisherigen Wert sichern, um im Fehlerfall zurückrollen zu können
const previousArticle = queryClient.getQueryData<Article>([
"article",
articleId,
]);
// Cache optimistisch aktualisieren
queryClient.setQueryData<Article>(["article", articleId], (old) =>
old ? { ...old, likes: old.likes + 1 } : old
);
return { previousArticle };
},
onError: (_err, _variables, context) => {
// Rollback bei Fehler
if (context?.previousArticle) {
queryClient.setQueryData(
["article", articleId],
context.previousArticle
);
}
},
onSettled: () => {
// Nach Erfolg oder Fehler: mit Serverzustand abgleichen
queryClient.invalidateQueries({ queryKey: ["article", articleId] });
},
});
}Das ist der Punkt, an dem viele Blogposts aufhören — aber gerade der Rollback-Mechanismus über onMutate/onError/onSettled ist entscheidend für ein robustes UX. Ohne Rollback riskierst du, dass die UI bei einem fehlgeschlagenen Request dauerhaft einen falschen Zustand zeigt.
Pagination
Nicht jede Artikel-Liste soll endlos scrollen — oft ist klassische Seiten-Navigation (Seite 1, 2, 3 …) die bessere Wahl, etwa in einem Admin-Bereich, wo Nutzer gezielt zu einer bestimmten Seite springen wollen. TanStack Query unterstützt das ganz ohne Spezial-Hook, einfach über die Seitenzahl im Query Key:
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function useArticlePage(page: number) {
return useQuery({
queryKey: ["articles", "page", page],
queryFn: () => fetchArticles({ page }),
staleTime: 60 * 1000,
// v5: hält die Daten der vorherigen Seite sichtbar, bis die neue
// geladen ist — verhindert das Leer-dann-voll-Flackern beim Blättern
placeholderData: keepPreviousData,
});
}function ArticleTable({ page }: { page: number }) {
const { data: articles, isPending, isPlaceholderData } = useArticlePage(page);
if (isPending) return <p>Lade Seite {page}…</p>;
return (
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{articles.map((article) => (
<li key={article.id}>{article.title}</li>
))}
</ul>
);
}Der entscheidende Teil ist placeholderData: keepPreviousData: Ohne diese Option zeigt TanStack Query beim Seitenwechsel kurz einen leeren isPending-Zustand, bevor die neue Seite da ist — mit ihr bleiben die alten Daten sichtbar (leicht abgedunkelt über isPlaceholderData), bis die neuen eingetroffen sind. Das fühlt sich beim Blättern deutlich ruhiger an als ein Loading-Flackern bei jedem Klick.
Kombiniert mit Prefetching aus dem vorigen Abschnitt lässt sich das noch weiter beschleunigen — die nächste Seite schon beim Hover über den "Weiter"-Button laden:
function Pagination({ page, onPageChange }: { page: number; onPageChange: (p: number) => void }) {
const queryClient = useQueryClient();
function prefetchNextPage() {
queryClient.prefetchQuery({
queryKey: ["articles", "page", page + 1],
queryFn: () => fetchArticles({ page: page + 1 }),
staleTime: 60 * 1000,
});
}
return (
<button onClick={() => onPageChange(page + 1)} onMouseEnter={prefetchNextPage}>
Weiter →
</button>
);
}Bereits besuchte Seiten kommen dank staleTime außerdem direkt aus dem Cache — springt ein Nutzer innerhalb einer Minute zwischen Seite 1 und 2 hin und her, wird nicht neu geladen.
Infinite Queries für endloses Scrollen
Für eine Artikel-Liste, bei der Infinite Scroll die bessere Wahl ist als Seiten-Navigation — z. B. ein Feed, den Nutzer eher durchscrollen als gezielt ansteuern —, gibt es useInfiniteQuery:
import { useInfiniteQuery } from "@tanstack/react-query";
interface ArticlePage {
articles: Article[];
nextCursor: string | null;
}
async function fetchArticlePage({
pageParam,
}: {
pageParam: string | null;
}): Promise<ArticlePage> {
const res = await fetch(`/api/articles?cursor=${pageParam ?? ""}`);
return res.json();
}
function useInfiniteArticles() {
return useInfiniteQuery({
queryKey: ["articles", "infinite"],
queryFn: fetchArticlePage,
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
function ArticleFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteArticles();
return (
<div>
{data?.pages.map((page) =>
page.articles.map((article) => (
<ArticleCard key={article.id} articleId={article.id} />
))
)}
{hasNextPage && (
<button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? "Lädt..." : "Mehr laden"}
</button>
)}
</div>
);
}getNextPageParam entscheidet, ob und wie die nächste Seite geladen wird — hier über einen Cursor, den die API zurückgibt. TanStack Query kümmert sich darum, alle geladenen Seiten im Cache zu verwalten und zusammenzuführen.
select: Gezielt Daten extrahieren
Manchmal braucht eine Komponente nur einen Bruchteil der geladenen Daten — etwa nur Titel und Datum, nicht den vollständigen Artikel-Content. Mit select transformierst du die Daten, ohne den Cache selbst zu verändern:
function useArticleTitles() {
return useQuery({
queryKey: ["articles"],
queryFn: fetchArticles,
select: (articles) =>
articles.map(({ id, title, publishedAt }) => ({
id,
title,
publishedAt,
})),
});
}Der Vorteil: Rendert eine Komponente nur basierend auf dem select-Ergebnis, re-rendert sie nur, wenn sich dieses Ergebnis tatsächlich ändert — nicht bei jeder Änderung am vollständigen Artikel-Objekt. Das kann bei großen, verschachtelten Datenstrukturen einen spürbaren Performance-Unterschied machen.
Retry-Strategien
TanStack Query versucht eine fehlgeschlagene Query standardmäßig dreimal erneut, mit exponentiellem Backoff zwischen den Versuchen, bevor sie als isError markiert wird. Das ist oft genau richtig — ein kurzer Netzwerk-Hänger soll nicht sofort eine Fehlermeldung auslösen —, aber nicht immer:
function useArticle(articleId: string) {
return useQuery({
queryKey: ["article", articleId],
queryFn: () => fetchArticle(articleId),
retry: (failureCount, error) => {
// Bei 404 macht ein Retry keinen Sinn, der Artikel existiert schlicht nicht
if (error instanceof HttpError && error.status === 404) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
});
}Die Faustregel: Retry ist sinnvoll bei transienten Fehlern (Netzwerk, 5xx-Serverfehler), aber sinnlos bei Fehlern, die sich durch Wiederholung nicht ändern (4xx-Client-Fehler wie 404 oder 401). Ein globales retry: false für alle Mutations ist ebenfalls üblich — bei einer Artikel-Erstellung willst du in der Regel nicht, dass ein fehlgeschlagener POST automatisch dupliziert wird.
Globale Fehlerbehandlung
Fehler in jeder einzelnen Komponente über isError/error abzufangen, ist gut für UI-spezifisches Verhalten (z. B. eine leere Liste vs. eine Fehlermeldung) — aber für Dinge wie ein globales Error-Toast oder zentrales Logging willst du das nicht in jeder Komponente wiederholen. Dafür gibt es QueryCache und MutationCache auf Ebene des QueryClient:
import { QueryClient, QueryCache, MutationCache } from "@tanstack/react-query";
import { toast } from "./toast";
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Nur global toasten, wenn schon Daten im Cache waren –
// beim allerersten Laden übernimmt meist die Komponente selbst die Fehleranzeige
if (query.state.data !== undefined) {
toast.error(`Aktualisierung fehlgeschlagen: ${error.message}`);
}
},
}),
mutationCache: new MutationCache({
onError: (error) => {
toast.error(`Aktion fehlgeschlagen: ${error.message}`);
},
}),
});Damit hast du eine zentrale Stelle für Logging (z. B. an Sentry) und konsistentes Nutzer-Feedback, ohne dass jede Komponente das selbst umsetzen muss. Komponenten-lokales isError-Handling und globales Error-Handling schließen sich nicht aus — meist ergänzen sie sich: lokal für die UI-Darstellung, global für Logging und übergreifendes Feedback.
React Query Devtools
Für die Entwicklung lohnt sich fast immer das offizielle Devtools-Paket — es zeigt live, welche Queries im Cache liegen, in welchem Zustand (fresh, stale, fetching, inactive) und mit welchen Daten:
npm install @tanstack/react-query-devtoolsimport { ReactQueryDevtools } from "@tanstack/react-query-devtools";
function App() {
return (
<QueryClientProvider client={queryClient}>
<ArticleList />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}Die Devtools rendern nur im Development-Build und fliegen aus dem Production-Bundle raus. Gerade beim Debuggen von Cache-Problemen — "warum wird hier nicht neu geladen?", "welchen Query Key hat dieser Eintrag eigentlich?" — sparen sie enorm viel Zeit gegenüber console.log-Debugging.
Häufige Fehler im Überblick
Retry blind auf Standard belassen, auch bei Endpunkten, die häufig 404 oder 401 liefern — das kostet Zeit ohne Nutzen und verzögert die Fehleranzeige unnötig.
staleTime: 0überall, obwohl viele Daten (Kategorien, Autoren-Profile, Tags) sich selten ändern und von einem höheren Wert profitieren würden.Optimistic Updates ohne Rollback, wodurch die UI bei einem fehlgeschlagenen Request dauerhaft einen falschen Zustand zeigt.
Fazit
Prefetching, klassische Pagination mit keepPreviousData, Optimistic Updates, Infinite Queries, select, gezielte Retry-Strategien, zentrale Fehlerbehandlung und die Devtools — das ist der Unterschied zwischen einer App, die einfach nur Daten anzeigt, und einer, die sich schnell und durchdacht anfühlt. Keins dieser Patterns ist für sich genommen kompliziert; der eigentliche Gewinn liegt darin, sie dort einzusetzen, wo sie den größten Effekt haben, statt sie überall gleichzeitig anzuwenden.
Was hier noch fehlt, ist der Sprung von "funktioniert gut in der Demo" zu "trägt in Produktion" — Authentifizierung, Server-Side Rendering, Cache-Persistenz und eine Ordnerstruktur, die auch bei vielen Queries übersichtlich bleibt. Genau darum geht's im nächsten Teil dieser Reihe: Fetch like a pro, Teil 3 — TanStack Query in Produktion.