Vue Query (TanStack)
Intelligentes Server-State-Management mit Caching und Synchronisation
Warum Server State anders ist
In jeder Webanwendung gibt es zwei Arten von Zustand: Client State (UI-Zustand wie „ist das Modal offen?") und Server State (Daten, die auf dem Server leben — Benutzer, Produkte, Bestellungen).
Server State hat besondere Herausforderungen:
- Die Daten gehören dir nicht — sie können sich jederzeit auf dem Server ändern.
- Caching ist nötig, um unnötige Requests zu vermeiden.
- Daten können veraltet (stale) sein und müssen revalidiert werden.
- Mehrere Komponenten können dieselben Daten benötigen.
- Fehlerbehandlung und Ladezustände müssen konsistent sein.
Vue Query (Teil von TanStack Query) löst genau diese Probleme. Es ist kein Ersatz für Pinia — es ergänzt Pinia, indem es Server State separat verwaltet.
Rails-Vergleich: ActiveRecord Caching
In Rails nutzt du Rails.cache.fetch, Fragment-Caching oder expires_in auf Modellen, um DB-Abfragen zu cachen. Vue Query macht dasselbe auf der Client-Seite: Es cached API-Responses, invalidiert sie nach einer bestimmten Zeit und holt automatisch frische Daten. Stell dir vor, Rails.cache wäre reaktiv und würde deine Views automatisch aktualisieren — das ist Vue Query.
Installation und Setup
npm install @tanstack/vue-queryRegistriere das Plugin in deiner Nuxt-App oder main.ts:
import { VueQueryPlugin, type VueQueryPluginOptions } from '@tanstack/vue-query'
export default defineNuxtPlugin((nuxtApp) => {
const options: VueQueryPluginOptions = {
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 Minuten
gcTime: 10 * 60 * 1000, // 10 Minuten
},
},
},
}
nuxtApp.vueApp.use(VueQueryPlugin, options)
})Nuxt-Integration
Lege die Datei unter plugins/vue-query.ts ab — Nuxt registriert sie automatisch.
useQuery — Daten lesen
useQuery ist das Herzstück von Vue Query. Es benötigt zwei Dinge: einen Query Key (zur Identifikation im Cache) und eine Query Function (die den eigentlichen Fetch ausführt).
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
interface User {
id: number
name: string
email: string
}
// Query definieren
const { data, error, isPending, isFetching } = useQuery({
queryKey: ['users'],
queryFn: async (): Promise<User[]> => {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Fehler beim Laden')
return res.json()
},
})
</script>
<template>
<!-- Erster Ladevorgang -->
<div v-if="isPending">Lade Benutzer...</div>
<!-- Fehler -->
<div v-else-if="error">Fehler: {{ error.message }}</div>
<!-- Daten anzeigen -->
<ul v-else>
<li v-for="user in data" :key="user.id">
{{ user.name }} — {{ user.email }}
</li>
</ul>
<!-- Hintergrund-Refetch-Indikator -->
<span v-if="isFetching && !isPending">Aktualisiere...</span>
</template> Vue Query liefert reaktive Refs zurück: data, error, isPending, isError, isFetching und mehr. Du bindest sie einfach im Template.
Query Keys
Query Keys sind Arrays, die den Cache-Eintrag eindeutig identifizieren. Vue Query invalidiert und refetcht automatisch, wenn sich der Key ändert.
// Einfacher Key — alle Benutzer
useQuery({ queryKey: ['users'], queryFn: fetchUsers })
// Key mit Parameter — ein einzelner Benutzer
useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) })
// Key mit Filtern — reaktiv!
const filters = ref({ role: 'admin', page: 1 })
useQuery({
queryKey: ['users', filters],
queryFn: () => fetchUsers(filters.value),
})
// → Neuer Fetch bei jeder Änderung von filtersWichtig
Query Keys sind hierarchisch. Wenn du ['users'] invalidierst, werden auch ['users', 42] und ['users', { role: 'admin' }] invalidiert.
Caching, Stale Time und Refetching
Vue Query verwendet ein intelligentes Cache-System mit zwei Zeitfenstern:
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
// Daten gelten 5 Minuten als frisch
staleTime: 5 * 60 * 1000,
// Inaktive Daten bleiben 30 Minuten im Cache
gcTime: 30 * 60 * 1000,
// Automatisches Refetching konfigurieren
refetchOnWindowFocus: true, // Standard: true
refetchOnReconnect: true, // Standard: true
refetchOnMount: true, // Standard: true
refetchInterval: false, // Polling deaktiviert
})- staleTime: Wie lange Daten als „frisch" gelten. Innerhalb dieser Zeit wird kein neuer Request ausgelöst.
- gcTime (Garbage Collection Time): Wie lange inaktive Daten im Cache bleiben, bevor sie entfernt werden.
Automatisches Refetching passiert bei:
- Fenster-Fokus (
refetchOnWindowFocus) - Netzwerk-Reconnect (
refetchOnReconnect) - Mount einer Komponente (
refetchOnMount) - Manuell via
refetch()oder Invalidierung
Rails-Vergleich
staleTime entspricht expires_in in Rails' Cache. gcTime ist vergleichbar mit der TTL deines Redis-Cache. Der Unterschied: Vue Query zeigt veraltete Daten sofort an und aktualisiert im Hintergrund — stale-while-revalidate, wie HTTP Cache-Control.
useMutation — Daten schreiben
Für Schreiboperationen (POST, PUT, DELETE) nutzt du useMutation. Es verwaltet Ladezustand, Fehler und Erfolgs-Callbacks.
<script setup lang="ts">
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient()
const { mutate, isPending, isError, error } = useMutation({
mutationFn: async (newUser: { name: string; email: string }) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
})
if (!res.ok) throw new Error('Erstellen fehlgeschlagen')
return res.json()
},
onSuccess: () => {
// Cache invalidieren → löst automatisch Refetch aus
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
const name = ref('')
const email = ref('')
function handleSubmit() {
mutate({ name: name.value, email: email.value })
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="name" placeholder="Name" required />
<input v-model="email" type="email" placeholder="Email" required />
<button :disabled="isPending">
{{ isPending ? 'Speichern...' : 'Benutzer erstellen' }}
</button>
<p v-if="isError" class="error">{{ error?.message }}</p>
</form>
</template>Optimistic Updates
Für eine schnellere UX kannst du den Cache sofort aktualisieren, bevor der Server antwortet. Falls der Request fehlschlägt, wird der alte Zustand automatisch wiederhergestellt.
const queryClient = useQueryClient()
const { mutate } = useMutation({
mutationFn: (updatedUser: User) =>
fetch(`/api/users/${updatedUser.id}`, {
method: 'PUT',
body: JSON.stringify(updatedUser),
}),
// Optimistic Update
onMutate: async (newUser) => {
// Laufende Queries abbrechen
await queryClient.cancelQueries({ queryKey: ['users'] })
// Alten Zustand sichern (für Rollback)
const previousUsers = queryClient.getQueryData(['users'])
// Cache sofort aktualisieren
queryClient.setQueryData(['users'], (old: User[]) =>
old.map(u => u.id === newUser.id ? newUser : u)
)
return { previousUsers }
},
// Bei Fehler: Rollback
onError: (_err, _newUser, context) => {
queryClient.setQueryData(['users'], context?.previousUsers)
},
// Immer: Frische Daten holen
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})Achtung
Optimistic Updates sind mächtig, aber komplex. Nutze sie nur bei Aktionen, die selten fehlschlagen (z.B. Like-Button), nicht bei kritischen Operationen wie Zahlungen.
Vue Query DevTools
Die DevTools zeigen dir alle Queries, deren Status, Cache-Daten und Timing. Unverzichtbar für Debugging.
<script setup>
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
</script>
<template>
<NuxtPage />
<!-- Nur in Entwicklung sichtbar -->
<VueQueryDevtools v-if=""production" === 'development'" />
</template>Live-Demo: Benutzerliste mit Ladezustand
Diese Demo simuliert einen API-Call mit Lade- und Fehlerzuständen. Klicke auf „Neu laden", um den Fetch erneut auszulösen.
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
interface User {
id: number
name: string
email: string
}
const {
data: users,
error,
isPending,
isFetching,
refetch,
} = useQuery({
queryKey: ['users'],
queryFn: async (): Promise<User[]> => {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Laden fehlgeschlagen')
return res.json()
},
staleTime: 30 * 1000, // 30 Sekunden
})
</script>
<template>
<div>
<button @click="refetch()">🔄 Neu laden</button>
<span v-if="isFetching">Aktualisiere...</span>
<div v-if="isPending">Lade Benutzer...</div>
<div v-else-if="error">Fehler: {{ error.message }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }} — {{ user.email }}
</li>
</ul>
</div>
</template>Zusammenfassung
Das Wichtigste
- Server State ≠ Client State — verwende Vue Query für API-Daten, Pinia für UI-Zustand.
- useQuery cached Daten automatisch und zeigt veraltete Daten sofort an.
- useMutation verwaltet Schreiboperationen mit Fehler-Handling.
- Query Keys sind hierarchisch — Invalidierung kaskadiert nach unten.
- Optimistic Updates machen die UX snappier, aber nutze sie mit Bedacht.
- Die DevTools sind unverzichtbar zum Debuggen von Cache-Verhalten.