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.
// 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 UserSSR-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:
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:
<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:
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 })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:
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
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 gespeichertuseMediaQuery
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
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()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-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
| Pattern | Wann nutzen | Beispiel |
|---|---|---|
| Singleton State | Globale Daten (Auth, Theme) | useAuth() |
| Async Data | API-Aufrufe mit Loading/Error | useFetchData(url) |
| Options Object | Konfigurierbare Logik | useDebounce(val, { delay }) |
| Composition | Komplexe Features aus Bausteinen | useUserSearch() |
| LocalStorage Sync | Persistenter Client-State | useLocalStorage(key, default) |