Modul 4: Composition API

Composable-Patterns

Fortgeschrittene Patterns für robuste, wiederverwendbare Composables – von State-Sharing bis Async.

State-Sharing zwischen Komponenten

Standardmäßig hat jeder Aufruf eines Composables seinen eigenen State. Manchmal willst du aber globalen State teilen – zum Beispiel den aktuellen User oder Theme-Einstellungen. Der Trick: Definiere den State außerhalb der Funktion.

composables/useAuth.ts
// State AUSSERHALB der Funktion = Singleton (geteilt)
const currentUser = ref<User | null>(null)
const isAuthenticated = computed(() => currentUser.value !== null)
export function useAuth() {
  async function login(email: string, password: string) {
    const response = await $fetch('/api/auth/login', {
      method: 'POST',
      body: { email, password }
    })
    currentUser.value = response.user
  }
  async function logout() {
    await $fetch('/api/auth/logout', { method: 'POST' })
    currentUser.value = null
  }
  return {
    user: readonly(currentUser),
    isAuthenticated,
    login,
    logout
  }
}
// In Komponente A: useAuth().user → gleicher User
// In Komponente B: useAuth().user → gleicher User
⚠️

SSR-Warnung

Bei Server-Side Rendering (SSR) teilen sich alle Requests denselben Modul-Scope. In Nuxt nutze stattdessen useState() – das erstellt Request-isolierten State. Für Client-only State ist das Singleton-Pattern aber perfekt.

🛤️

Rails-Vergleich

Das Singleton-Pattern erinnert an Current in Rails (Current.user). Der Unterschied: In Vue ist der Singleton reaktiv – wenn sich user ändert, aktualisieren sich alle Komponenten automatisch.

Async Composables

API-Aufrufe sind der häufigste Use Case in jeder App. Ein gutes Async-Composable verwaltet Loading-State, Error-Handling und die eigentlichen Daten an einem Ort:

composables/useFetchData.ts
export function useFetchData<T>(url: MaybeRef<string>) {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(null)
  const isLoading = ref(false)
  async function execute() {
    isLoading.value = true
    error.value = null
    try {
      data.value = await $fetch<T>(toValue(url))
    } catch (err) {
      error.value = err instanceof Error
        ? err
        : new Error('Unbekannter Fehler')
    } finally {
      isLoading.value = false
    }
  }
  // Automatisch laden & bei URL-Änderung neu laden
  watch(() => toValue(url), execute, { immediate: true })
  return { data, error, isLoading, refresh: execute }
}

Nutzung in einer Komponente:

pages/users.vue
<template>
  <div>
    <p v-if="isLoading">Laden...</p>
    <p v-else-if="error" class="text-red-500">{{ error.message }}</p>
    <ul v-else-if="data">
      <li v-for="user in data" :key="user.id">
        {{ user.name }}
      </li>
    </ul>
  </div>
</template>
<script setup lang="ts">
interface User { id: number; name: string }
const { data, error, isLoading } = useFetchData<User[]>('/api/users')
</script>
💡

Nuxt useFetch / useAsyncData

In Nuxt brauchst du das nicht selbst zu bauen! useFetch() und useAsyncData() liefern das gleiche Pattern out-of-the-box, inklusive SSR-Hydration, Caching und automatischem Refetch. Aber das Verständnis des Patterns hilft dir, die Nuxt-APIs besser einzusetzen.

Composable-Options-Pattern

Für konfigurierbare Composables übergibst du ein Options-Objekt. Das ist lesbarer als viele Positionsparameter und lässt sich leicht erweitern:

composables/useDebounce.ts
interface UseDebounceOptions {
  delay?: number
  immediate?: boolean
}
export function useDebounce<T>(
  value: Ref<T>,
  options: UseDebounceOptions = {}
) {
  const { delay = 300, immediate = false } = options
  const debouncedValue = ref(value.value) as Ref<T>
  let timer: ReturnType<typeof setTimeout> | null = null
  watch(value, (newVal) => {
    if (timer) clearTimeout(timer)
    if (immediate && !timer) {
      debouncedValue.value = newVal
    }
    timer = setTimeout(() => {
      debouncedValue.value = newVal
      timer = null
    }, delay)
  })
  return debouncedValue
}
// Nutzung:
// const search = ref('')
// const debouncedSearch = useDebounce(search, { delay: 500 })
useDebounce Demo
Interaktiv
Eingabe: Debounced (500ms):

Composable-Composition: Composables in Composables

Die wahre Stärke zeigt sich, wenn Composables andere Composables nutzen. So baust du komplexe Logik aus einfachen Bausteinen – wie Lego:

composables/useUserSearch.ts
export function useUserSearch() {
  const query = ref('')
  // Composable 1: Debounce der Eingabe
  const debouncedQuery = useDebounce(query, { delay: 400 })
  // Composable 2: API-Call mit dem debouncten Wert
  const url = computed(() =>
    debouncedQuery.value
      ? `/api/users?search=${debouncedQuery.value}`
      : null
  )
  const { data: users, isLoading, error } = useFetchData(url)
  // Composable 3: Suchhistorie in LocalStorage
  const history = useLocalStorage<string[]>('search-history', [])
  watch(debouncedQuery, (val) => {
    if (val && !history.value.includes(val)) {
      history.value = [val, ...history.value].slice(0, 10)
    }
  })
  return {
    query,
    users,
    isLoading,
    error,
    history: readonly(history)
  }
}
ℹ️

useUserSearch kombiniert drei Composables: useDebounce für die verzögerte Eingabe, useFetchData für den API-Call, und useLocalStorage für persistente Suchhistorie. Jedes Composable kümmert sich um seine Aufgabe – zusammen ergeben sie eine komplette Suchfunktion.

🛤️

Rails-Vergleich

In Rails würdest du das mit verschachtelten Service Objects lösen: UserSearchService nutzt ElasticSearchClient und SearchHistoryRecorder. Das gleiche Prinzip – aber die Vue-Variante ist dank Reaktivität automatisch synchronisiert.

Praxisbeispiele

Hier sind drei Composables, die in fast jedem Projekt nützlich sind:

useLocalStorage

composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const data = ref<T>(defaultValue) as Ref<T>
  onMounted(() => {
    const stored = localStorage.getItem(key)
    if (stored) {
      try {
        data.value = JSON.parse(stored)
      } catch {
        data.value = defaultValue
      }
    }
  })
  watch(data, (val) => {
    localStorage.setItem(key, JSON.stringify(val))
  }, { deep: true })
  return data
}
// Nutzung:
// const theme = useLocalStorage('theme', 'dark')
// theme.value = 'light' → automatisch in localStorage gespeichert

useMediaQuery

composables/useMediaQuery.ts
export function useMediaQuery(query: string) {
  const matches = ref(false)
  onMounted(() => {
    const mediaQuery = window.matchMedia(query)
    matches.value = mediaQuery.matches
    const handler = (e: MediaQueryListEvent) => {
      matches.value = e.matches
    }
    mediaQuery.addEventListener('change', handler)
    onUnmounted(() => {
      mediaQuery.removeEventListener('change', handler)
    })
  })
  return matches
}
// Nutzung:
// const isMobile = useMediaQuery('(max-width: 768px)')
// const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')

useOnlineStatus

composables/useOnlineStatus.ts
export function useOnlineStatus() {
  const isOnline = ref(true)
  function onOnline() { isOnline.value = true }
  function onOffline() { isOnline.value = false }
  onMounted(() => {
    isOnline.value = navigator.onLine
    window.addEventListener('online', onOnline)
    window.addEventListener('offline', onOffline)
  })
  onUnmounted(() => {
    window.removeEventListener('online', onOnline)
    window.removeEventListener('offline', onOffline)
  })
  return readonly(isOnline)
}
// Nutzung:
// const isOnline = useOnlineStatus()
useOnlineStatus Demo
Interaktiv
Online (Schalte dein WLAN aus, um den Effekt zu sehen)

Best Practices und Anti-Patterns

✅ Do

  • Eine Aufgabe pro Composable
  • Klare, beschreibende Namen
  • Dokumentiere Parameter und Rückgabewerte
  • Cleanup in onUnmounted
  • Readonly für geschützten State

❌ Don't

  • Riesige „God"-Composables mit 500 Zeilen
  • Direkte DOM-Manipulation
  • Seiteneffekte ohne Cleanup
  • Globalen State versehentlich erstellen
  • Composables conditional aufrufen
Anti-Patterns
// ❌ Anti-Pattern: Composable conditional aufrufen
if (someCondition) {
  const { data } = useFetch('/api/users')  // NIEMALS!
}
// ✅ Stattdessen: Immer aufrufen, Bedingung intern
const { data } = useFetch(
  computed(() => someCondition ? '/api/users' : null)
)
// ❌ Anti-Pattern: Versehentlicher globaler State
const items = ref([])  // Außerhalb = geteilt!
export function useItems() {
  return { items }
}
// ✅ Korrekt: State innerhalb (eigene Instanz pro Aufruf)
export function useItems() {
  const items = ref([])
  return { items }
}
🚫

Häufiger Fehler: Versehentlicher Singleton

Wenn du const data = ref([]) außerhalb der Funktion definierst, teilen sich alle Komponenten diesen State. Das ist manchmal gewollt (Auth, Theme), aber oft ein Bug. Definiere State immer innerhalb der Funktion, es sei denn, du willst explizit ein Singleton.

Zusammenfassung

PatternWann nutzenBeispiel
Singleton StateGlobale Daten (Auth, Theme)useAuth()
Async DataAPI-Aufrufe mit Loading/ErroruseFetchData(url)
Options ObjectKonfigurierbare LogikuseDebounce(val, { delay })
CompositionKomplexe Features aus BausteinenuseUserSearch()
LocalStorage SyncPersistenter Client-StateuseLocalStorage(key, default)