Modul 7: Vue Ökosystem

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

Terminal
npm install @tanstack/vue-query

Registriere das Plugin in deiner Nuxt-App oder main.ts:

plugins/vue-query.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).

UserList.vue
<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.

setup
// 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 filters
ℹ️

Wichtig

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:

setup
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.

CreateUser.vue
<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.

setup
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.

App.vue
<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.

Vue Query Demo
Interaktiv
UserListDemo.vue
<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.