Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Ich habe Produktions-Apps mit TanStack Query v5 (früher React Query) in mehreren SaaS-Produkten ausgeliefert. Diese Guides basieren auf direkter Produktionserfahrung: echtem Code, gemessenem Verhalten und ehrlichen Einschränkungen.
Verifizierte Quellen: tanstack.com, github.com/TanStack, web.dev, developers.mozilla.org. Zuletzt aktualisiert: 2026-10-05 · Changelog
TanStack Query (früher React Query) v5 brachte deutliche API-Verbesserungen und Performance-Gewinne. Nach Dutzenden Produktions-Apps sind hier die Patterns, die wirklich in der Skalierung funktionieren.
Query Key Factories
Das wirkungsvollste Pattern: strukturierte Query-Key-Factories. Ad-hoc-String-Keys erzeugen subtile Bugs und machen Cache-Invalidierung unvorhersehbar.
// queries/userKeys.ts
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
// Verwendung
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
})
// Alle User-Queries invalidieren
queryClient.invalidateQueries({ queryKey: userKeys.all })
// Nur Listen-Queries invalidieren
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
Typsichere queryOptions
v5 führt queryOptions() für wiederverwendbare, typsichere Query-Konfigurationen ein:
import { queryOptions } from '@tanstack/react-query'
export const userQueryOptions = (userId: string) =>
queryOptions({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5 Minuten
})
// In einer Komponente
const { data: user } = useQuery(userQueryOptions(userId))
// In einem Route-Loader (TanStack-Router-Integration)
export const Route = createFileRoute('/users/$userId')({
loader: ({ context, params }) =>
context.queryClient.ensureQueryData(userQueryOptions(params.userId)),
})
Bewusste Stale Times
Nicht überall den Default staleTime: 0 verwenden. Denkt daran, wie oft sich Daten ändern:
// User-Präferenzen ändern sich selten — 1 Stunde cachen
const { data: preferences } = useQuery({
queryKey: ['preferences'],
queryFn: fetchPreferences,
staleTime: 60 * 60 * 1000,
})
// Feed-Daten ändern sich ständig — kein Cache
const { data: feed } = useQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
staleTime: 0,
refetchInterval: 30_000,
})
// Produktkatalog — mittlerer Cache
const { data: products } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
staleTime: 5 * 60 * 1000,
})
Optimistic Mutations
Optimistic Updates lassen sich das UI sofort reagieren. In v5 ist das deutlich sauberer:
function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateTodo,
onMutate: async (updatedTodo) => {
// Laufende Queries abbrechen, um das Optimistic Update nicht zu überschreiben
await queryClient.cancelQueries({ queryKey: todoKeys.detail(updatedTodo.id) })
// Vorherigen Wert für Rollback snapshoten
const previous = queryClient.getQueryData(todoKeys.detail(updatedTodo.id))
// Optimistisch aktualisieren
queryClient.setQueryData(todoKeys.detail(updatedTodo.id), (old) => ({
...old,
...updatedTodo,
}))
return { previous }
},
onError: (err, updatedTodo, context) => {
// Bei Fehler zurückrollen
queryClient.setQueryData(todoKeys.detail(updatedTodo.id), context?.previous)
},
onSettled: (data, error, updatedTodo) => {
// Nach jeder Mutation immer neu fetchen
queryClient.invalidateQueries({ queryKey: todoKeys.detail(updatedTodo.id) })
},
})
}
Infinite Queries mit Cursor-Pagination
Cursor-basierte Pagination ist zuverlässiger als Offset-Pagination für Live-Daten:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts', filters],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam, ...filters }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// Seiten für das Rendering flatten
const posts = data?.pages.flatMap((page) => page.items) ?? []
Integration mit Error Boundaries
throwOnError von TanStack Query mit React Error Boundaries kombinieren:
// Fehler an den nächsten Error Boundary werfen
const { data } = useQuery({
queryKey: ['critical-data'],
queryFn: fetchCriticalData,
throwOnError: true,
})
// Im Route- oder Component-Tree
function RouteErrorBoundary({ error }: { error: Error }) {
return (
<div className="error-state">
<h2>Etwas ist schiefgelaufen</h2>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>Erneut versuchen</button>
</div>
)
}
Prefetching für bessere UX
Daten beim Hover prefetchen und Loading-States komplett eliminieren:
function PostCard({ post }: { post: Post }) {
const queryClient = useQueryClient()
return (
<Link
to="/posts/$postId"
params={{ postId: post.id }}
onMouseEnter={() => {
queryClient.prefetchQuery(postQueryOptions(post.id))
}}
>
{post.title}
</Link>
)
}
Fazit
Die wichtigsten Erkenntnisse:
- Query Key Factories — strukturiert eure Keys vom ersten Tag an
queryOptions()— Query-Konfiguration zwischen Komponenten und Loadern teilen- Bewusste Stale Times — Cache-Dauer an die Änderungsfrequenz der Daten anpassen
- Optimistic Updates — immer Rollback und Refetch nach Abschluss behandeln
- Prefetch bei Hover — der günstigste Performance-Gewinn überhaupt
Diese Patterns haben uns unzählige Stunden Debugging von stale-data- und Cache-Invalidierungs-Bugs in der Produktion erspart. Anfangen mit Key Factories und von dort aus ausbauen.