Modul 6: State – Pinia

Getters & Actions

Berechnete Werte und Funktionen in Pinia Stores – synchron und asynchron.

Getters: Berechnete Werte im Store

Getters sind computed()-Werte in deinem Store. Sie berechnen abgeleitete Daten aus dem State und werden automatisch gecacht – sie werden nur neu berechnet, wenn sich eine Abhängigkeit ändert.

stores/products.ts
// stores/products.ts
export const useProductStore = defineStore('products', () => {
  const products = ref<Product[]>([])
  const selectedCategory = ref<string | null>(null)

  // Einfacher Getter
  const productCount = computed(() => products.value.length)

  // Getter mit Filterlogik
  const filteredProducts = computed(() => {
    if (!selectedCategory.value) return products.value
    return products.value.filter(p => p.category === selectedCategory.value)
  })

  // Getter mit Parameter (gibt eine Funktion zurück)
  const getProductById = computed(() => {
    return (id: string) => products.value.find(p => p.id === id)
  })

  // Getter mit Aggregation
  const averagePrice = computed(() => {
    if (products.value.length === 0) return 0
    const total = products.value.reduce((sum, p) => sum + p.price, 0)
    return total / products.value.length
  })

  return {
    products, selectedCategory,
    productCount, filteredProducts, getProductById, averagePrice,
  }
})
🛤️

Rails-Vergleich: Getters

Getters sind wie berechnete Attribute in ActiveRecord-Models. Vergleiche computed(() => ...) mit einem Model-Method wie def full_name; "#{first_name} #{last_name}"; end – nur dass Pinia-Getters automatisch gecacht und reaktiv aktualisiert werden, ähnlich wie Rails Counter-Caches, aber ohne manuellen Invalidierungsaufwand.

Getters die andere Getters verwenden

Da Getters einfach computed()-Werte sind, können sie auf andere Getters und State-Refs zugreifen – alles ist reaktiv verknüpft.

stores/cart.ts – verschachtelte Getters
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  // Getter 1: Anzahl aller Artikel
  const itemCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  // Getter 2: Gesamtpreis
  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  // Getter 3: Nutzt andere Getters!
  const cartSummary = computed(() =>
    `${itemCount.value} Artikel – ${total.value.toFixed(2)} €`
  )

  // Getter 4: Nutzt State + anderen Getter
  const isEmpty = computed(() => itemCount.value === 0)

  return { items, itemCount, total, cartSummary, isEmpty }
})
💡

Getter-Ketten sind effizient

Vue's Reaktivitätssystem erkennt automatisch die Abhängigkeiten. Wenn sich items ändert, wird itemCount neu berechnet, was wiederum cartSummary auslöst – aber nur wenn nötig.

Actions: Synchron

Actions sind normale Funktionen, die den State verändern. In der Setup-Syntax gibt es keinen Unterschied zwischen „Mutations" und „Actions" – alles sind einfach Funktionen.

stores/cart.ts – synchrone Actions
// stores/cart.ts – Actions
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  function addItem(product: Product) {
    const existing = items.value.find(i => i.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  function removeItem(productId: string) {
    const index = items.value.findIndex(i => i.id === productId)
    if (index > -1) {
      if (items.value[index].quantity > 1) {
        items.value[index].quantity--
      } else {
        items.value.splice(index, 1)
      }
    }
  }

  function clear() {
    items.value = []
  }

  return { items, addItem, removeItem, clear }
})
ℹ️

Anders als Vuex braucht Pinia keine mutations. Du änderst State direkt in Actions – weniger Boilerplate, gleiche DevTools-Integration.

Actions: Asynchron

Async Actions sind perfekt für API-Calls. Du kannst async/await direkt verwenden – kein spezielles Middleware-System nötig.

stores/products.ts – async Actions
// stores/products.ts
export const useProductStore = defineStore('products', () => {
  const products = ref<Product[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function fetchProducts() {
    loading.value = true
    error.value = null

    try {
      products.value = await $fetch('/api/products')
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Unbekannter Fehler'
    } finally {
      loading.value = false
    }
  }

  async function createProduct(data: CreateProductDTO) {
    const newProduct = await $fetch('/api/products', {
      method: 'POST',
      body: data,
    })
    // Optimistisch zum State hinzufügen
    products.value.push(newProduct)
  }

  return { products, loading, error, fetchProducts, createProduct }
})
🛤️

Rails-Vergleich: Async Actions

Async Actions sind vergleichbar mit Service Objects in Rails. Wie ein OrderCreator.call(params) orchestrieren sie mehrere Schritte (API-Call, Validierung, State-Änderung) in einer Funktion. Der Unterschied: In Rails ist das serverseitig und synchron pro Request – in Pinia ist es clientseitig und non-blocking.

Actions die andere Actions aufrufen

Actions können andere Actions aufrufen – auch aus anderen Stores. So baust du komplexe Workflows aus kleinen, testbaren Bausteinen.

stores/checkout.ts
// stores/checkout.ts
export const useCheckoutStore = defineStore('checkout', () => {
  const processing = ref(false)

  async function placeOrder() {
    const cartStore = useCartStore()    // Anderen Store verwenden
    const userStore = useUserStore()

    if (cartStore.isEmpty) {
      throw new Error('Warenkorb ist leer')
    }

    processing.value = true
    try {
      await $fetch('/api/orders', {
        method: 'POST',
        body: {
          items: cartStore.items,
          userId: userStore.userId,
          total: cartStore.total,
        },
      })

      cartStore.clear()  // Action eines anderen Stores aufrufen
    } finally {
      processing.value = false
    }
  }

  return { processing, placeOrder }
})

Error Handling in Actions

Speichere Fehler-State direkt im Store. So kann jede Komponente auf Fehler reagieren – ob Fehlermeldung, Retry-Button oder Weiterleitung.

stores/auth.ts – Error Handling
// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function login(email: string, password: string) {
    loading.value = true
    error.value = null

    try {
      const response = await $fetch('/api/auth/login', {
        method: 'POST',
        body: { email, password },
      })
      user.value = response.user
    } catch (e: any) {
      // Fehler-State im Store speichern
      if (e.statusCode === 401) {
        error.value = 'E-Mail oder Passwort falsch'
      } else if (e.statusCode === 429) {
        error.value = 'Zu viele Versuche – bitte warte kurz'
      } else {
        error.value = 'Ein unerwarteter Fehler ist aufgetreten'
      }
      throw e  // Optional: Weiterwerfen für catch in der Komponente
    } finally {
      loading.value = false
    }
  }

  function clearError() {
    error.value = null
  }

  return { user, loading, error, login, clearError }
})
components/LoginForm.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'

const authStore = useAuthStore()
const { loading, error } = storeToRefs(authStore)

const email = ref('')
const password = ref('')

async function handleSubmit() {
  try {
    await authStore.login(email.value, password.value)
    await navigateTo('/dashboard')
  } catch {
    // Fehler ist bereits im Store – Anzeige erfolgt reaktiv
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <!-- Fehleranzeige direkt aus dem Store -->
    <div v-if="error" class="bg-red-500/10 text-red-400 p-3 rounded-lg mb-4">
      {{ error }}
    </div>

    <input v-model="email" type="email" @focus="authStore.clearError()" />
    <input v-model="password" type="password" />

    <button :disabled="loading">
      {{ loading ? 'Wird geladen...' : 'Anmelden' }}
    </button>
  </form>
</template>

$patch: Mehrere State-Änderungen gebündelt

Mit $patch() kannst du mehrere State-Änderungen in einem Schritt durchführen. Das ist effizienter als einzelne Zuweisungen, da Vue die Reaktivitäts-Updates bündelt.

$patch – zwei Varianten
const cartStore = useCartStore()

// Variante 1: Objekt – einfach und deklarativ
cartStore.$patch({
  items: [],
  lastCleared: new Date(),
})

// Variante 2: Funktion – für komplexere Logik
cartStore.$patch((state) => {
  state.items = state.items.filter(i => i.quantity > 0)
  state.lastUpdated = new Date()
})

// Ohne $patch: Zwei separate Reaktivitäts-Zyklen
cartStore.items = []           // → Vue reagiert
cartStore.lastCleared = new Date()  // → Vue reagiert nochmal

// Mit $patch: Ein Reaktivitäts-Zyklus
cartStore.$patch({
  items: [],
  lastCleared: new Date(),
})  // → Vue reagiert nur einmal ✅
💡

Wann $patch verwenden?

Verwende $patch() wenn du mehrere State-Werte gleichzeitig ändern willst – besonders bei Objekten und Arrays. Für einzelne Werte reicht eine direkte Zuweisung.

Demo: Warenkorb-Store

Ein vollständiger Warenkorb mit Getters für Berechnungen und Actions für alle Operationen.

stores/cart.ts – vollständig
// stores/cart.ts – vollständiger Warenkorb-Store
interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  // ── State ──
  const items = ref<CartItem[]>([])

  // ── Getters ──
  const itemCount = computed(() =>
    items.value.reduce((sum, i) => sum + i.quantity, 0)
  )

  const total = computed(() =>
    items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
  )

  const isEmpty = computed(() => items.value.length === 0)

  const formattedTotal = computed(() =>
    new Intl.NumberFormat('de-DE', {
      style: 'currency', currency: 'EUR',
    }).format(total.value)
  )

  // ── Actions ──
  function addItem(product: { id: string; name: string; price: number }) {
    const existing = items.value.find(i => i.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  function removeItem(productId: string) {
    const idx = items.value.findIndex(i => i.id === productId)
    if (idx === -1) return
    items.value[idx].quantity > 1
      ? items.value[idx].quantity--
      : items.value.splice(idx, 1)
  }

  function clear() {
    items.value = []
  }

  return {
    items, itemCount, total, isEmpty, formattedTotal,
    addItem, removeItem, clear,
  }
})
Warenkorb
Interaktiv

Produkte

Warenkorb (0 Artikel)

Noch leer – klicke auf ein Produkt!

Zusammenfassung

  • Getters = computed() – gecacht und reaktiv
  • ✅ Getters können andere Getters referenzieren
  • Sync Actions – direkte State-Änderungen
  • Async Actions – API-Calls mit async/await
  • Error State im Store für einheitliches Fehler-Handling
  • $patch() für gebündelte State-Änderungen