TanStack Query Best Practices: The Patterns That Separate 8% CTR from 0.5% Load Times

TanStack Query Best Practices: Query-Key-Organisation, staleTime vs. gcTime, Prefetching, Infinite Queries, SSR-Hydration und die Caching-Patterns, die Apps sofortig wirken lassen.

Huifer
Huifer
18. September 20263 min read
Auch verfügbar auf:English · 中文

Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship. Die TanStack-Query-Best-Practices, die ich heute verwende, sind nicht diejenigen, mit denen ich angefangen habe. Meine erste TanStack-Query-Implementierung hatte Query-Keys wie ['getUser', userId], ['fetchProduct'] und ['products'] – eine Namenskonvention, die funktionierte, bis ich 30 Queries hatte und keine Ahnung, welche davon stale waren. Ich habe einen Tag damit verbracht, die Query-Key-Konvention über drei Apps hinweg zu standardisieren, und das war der Refactoring-Schritt mit dem höchsten Hebelwert im gesamten Jahr. Dieser Artikel ist die Konvention, die ich nun in jeder neuen App verwende.

Verifizierte Quellen: TanStack Query documentation · TanStack Query best practices · TanStack Query GitHub Letzte Aktualisierung: 2026-09-18 · Changelog

TL;DR: Die drei TanStack-Query-Practices mit dem höchsten Hebelwert: (1) Query-Key-Konvention mit Punkten – ['users', userId, 'profile']; (2) staleTime auf das natürliche Aktualisierungsintervall der Daten setzen, nicht auf 0; (3) Kritische Routen beim Hover über Links vorladen. Alles andere ist Optimierung nach diesen drei Punkten.


Practice 1: Query-Key-Konvention

Die wichtigste Konvention in einer TanStack-Query-App ist der Query-Key. Query-Keys bestimmen die Cache-Identität – zwei Queries mit demselben Key teilen sich dieselben gecachten Daten. Eine schlechte Konvention macht Cache-Invalidierung unberechenbar. Eine gute Konvention macht sie offensichtlich.

Die punktgetrennte Konvention

typescript
// Jeder Query-Key folgt dem Schema: [entity, id?, subResource?]
// Das macht Invalidierung vorhersehbar und Keys vorhersehbar

// Einzelne Ressource nach ID
['users', userId]
['products', productId]
['orders', orderId]

// Unterressourcen einer einzelnen Entität
['users', userId, 'profile']
['users', userId, 'orders']
['products', productId, 'reviews']

// Collections (ohne ID)
['users']
['products']
['orders']

// Gefilterte Collections
['products', 'category', categorySlug]
['orders', 'status', 'pending']

Diese Konvention hat eine Regel: Entitätsnamen sind Plural, IDs sind Singular, Unterressourcen sind Singular. ['users', '123'] ist ein User. ['users', '123', 'orders'] sind die Orders dieses Users.

Warum das für die Invalidierung wichtig ist

Mit dieser Konvention ist die Invalidierung vorhersehbar:

typescript
// Alle Queries für einen bestimmten User invalidieren
queryClient.invalidateQueries({ queryKey: ['users', userId] })
// Invalidiert: ['users', userId], ['users', userId, 'profile'], ['users', userId, 'orders']

// Alle Queries für die Reviews eines bestimmten Produkts invalidieren
queryClient.invalidateQueries({ queryKey: ['products', productId, 'reviews'] })

// Alle Queries für einen Ressourcentyp invalidieren
queryClient.invalidateQueries({ queryKey: ['products'] })
// Invalidiert: ['products'], ['products', 'category', ...], ['products', productId, ...]

Ohne Konvention wissen Sie nie, welche Queries von einer Invalidierung betroffen sind. Mit der Konvention sagt Ihnen die Query-Key-Struktur genau, was passiert.


Practice 2: staleTime, Nicht 0

Der Standardwert staleTime: 0 bedeutet, dass jede Query bei jedem Mount neu geladen wird. Für Daten, die sich häufig ändern (Benachrichtigungen, Live-Metriken), ist das korrekt. Für Daten, die sich selten ändern (User-Profil, Produktkatalog), ist das eine Performance-Steuer.

typescript
// ❌ Standard: Neu laden bei jedem Mount – teuer
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  // staleTime beträgt standardmäßig 0
})

// ✅ staleTime auf das natürliche Aktualisierungsintervall der Daten setzen
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 1000 * 60 * 5, // 5 Minuten – User-Profil ändert sich selten
})

// ✅ Statische Referenzdaten: nie neu laden, außer explizit invalidiert
const { data: countries } = useQuery({
  queryKey: ['reference', 'countries'],
  queryFn: () => fetchCountries(),
  staleTime: 1000 * 60 * 60 * 24, // 24 Stunden
})

Das mentale Modell: staleTime gibt an, wie lange Sie bereit sind, stale Daten auszuliefern. Für User-Profil-Daten sind 5 Minuten in Ordnung. Für einen Aktienkurs: 0 Sekunden. Für eine Länderliste: Unendlichkeit.


Practice 3: Prefetch bei Link-Hover

Das UX-Pattern mit der höchsten Wirkung in TanStack Query: Daten vorladen, wenn der User über einen Link hovert. Die Daten sind im Cache, sobald er klickt.

typescript
import { useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'

function ProductList({ products }) {
  const queryClient = useQueryClient()

  return (
    <div className="product-grid">
      {products.map(product => (
        <Link
          key={product.id}
          to={`/products/${product.id}`}
          onMouseEnter={() => {
            // Prefetch bei Hover – Daten sind bereit, bevor geklickt wird
            queryClient.prefetchQuery({
              queryKey: ['products', product.id],
              queryFn: () => fetchProduct(product.id),
              staleTime: 1000 * 60 * 5,
            })
          }}
        >
          <ProductCard product={product} />
        </Link>
      ))}
    </div>
  )
}

Das Ergebnis: Ein Klick auf eine Produktkarte rendert die Detailseite sofort – die Query-Daten sind bereits im Cache. Das onMouseEnter feuert vor der Navigation und gibt Ihnen 50–200 ms Prefetch-Zeit.


Practice 4: Globale QueryClient-Standards

Setzen Sie globale Standards, die zu den Aktualisierungscharakteristika Ihrer Daten passen, nicht zu Randfällen:

typescript
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Standard: 2 Minuten staleTime
      // Das ist der richtige Standard für die meisten SaaS-Daten
      staleTime: 1000 * 60 * 2,

      // gcTime: 10 Minuten
      // Daten bleiben 10 Minuten nach dem letzten Subscriber im Speicher
      gcTime: 1000 * 60 * 10,

      // 3 Versuche mit exponentiellem Backoff
      retry: 3,
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex, 30000),

      // Bei Window-Focus neu laden für authentifizierte User
      // (Daten könnten sich geändert haben, während der Tab inaktiv war)
      refetchOnWindowFocus: true,

      // Nicht neu laden, wenn bereits ein Ladevorgang läuft
      refetchOnMount: false,
    },
    mutations: {
      // Standard-Retry für Mutations: 0
      // Mutations sollten laut fehlschlagen, nicht still erneut versuchen
      retry: 0,
    },
  },
})

Practice 5: SSR-Hydration mit v5

Für SSR-Apps hat sich das Hydration-Pattern in v5 geändert. Stellen Sie sicher, dass Server- und Client-State übereinstimmen:

typescript
// Server: Prefetch mit konsistenter staleTime
// (muss mit der staleTime des Clients übereinstimmen, damit v5-Hydration funktioniert)
const dehydratedState = dehydrate(
  new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 1000 * 60 * 5,
        gcTime: 1000 * 60 * 10,
      },
    },
  })
)
typescript
// Client: staleTime des Servers in der QueryClient-Konfiguration matchen
const [queryClient] = useState(
  () =>
    new QueryClient({
      defaultOptions: {
        queries: {
          // Muss mit der staleTime des Servers übereinstimmen für korrekte Hydration
          staleTime: 1000 * 60 * 5,
          gcTime: 1000 * 60 * 10,
        },
      },
    })
)

Die v5-Verbesserung: Wenn die Server-Daten frisch sind (innerhalb von staleTime), verwendet der Client sie direkt, ohne ein erneutes Laden. Abweichende staleTime-Werte zwischen Server und Client verursachen lautlose Neuabrufe bei der Hydration.


Practice 6: Background-Refetch-Strategie

Laden Sie nicht bei jedem Focus-Event alles neu. Verwenden Sie staleTime als primäre Refetch-Kontrolle und nutzen Sie Background-Refetch sparsam:

typescript
const { data, isFetching, isStale } = useQuery({
  queryKey: ['dashboard-metrics'],
  queryFn: () => fetchDashboardMetrics(),
  staleTime: 1000 * 60, // 1 Minute
  // Nur neu laden, wenn die Query aktuell stale IST UND das Fenster Fokus hat
  refetchOnWindowFocus: (query) => query.state.data !== undefined,
})

Die Funktionsform von refetchOnWindowFocus: nur neu laden, wenn wir bereits Daten haben. Das verhindert das UX-Anti-Pattern „Ladezustand bei jedem Tab-Wechsel".


Die 5-Punkte-Produktions-Checkliste

  • Query-Key-Konvention dokumentiert – jedes Teammitglied verwendet dieselbe Struktur
  • staleTime pro Query gesetzt – 0 ist die Ausnahme, nicht der Standard
  • Kritische Routen vorgeladen – Produktdetail- und User-Profil-Seiten bei Link-Hover
  • SSR-staleTime stimmt mit Client überein – führen Sie bei jeder SSR-Änderung einen Hydration-Smoke-Test durch
  • QueryClient-gcTime beträgt mindestens 10 Minuten – zu niedrig und die Rücknavigation fühlt sich langsam an

TanStack Ship wird mit TanStack Query v5 ausgeliefert – mit vordefinierter Query-Key-Konvention und SSR-Hydration. Weitere Informationen finden Sie im TanStack Query v5 Guide und in der vollständigen Feature-Liste.