← Zurück zum Blog
Vue 3 hat das Frontend-Ökosystem 2026 fest im Griff. Mit der Composition API,
vollständiger TypeScript-Unterstützung und Pinia als offiziellem State-Manager ist Vue 3 zur
ersten Wahl für skalierbare Single-Page-Apps und Nuxt-3-Projekte geworden. In diesem Artikel
zeigen wir, wie Claude Code als KI-gestützter Vue-Experte das komplette Setup —
von defineStore bis zur serverseitigen Persistenz — in Minuten aufbaut.
1. Vue 3 Composition API
Die Composition API ist das Herzstück von Vue 3. Sie löst die Options-API ab
und ermöglicht es, Logik nach Funktion statt nach Typ zu gruppieren. Das Ergebnis: bessere
Lesbarkeit, einfaches Testing und vollständige TypeScript-Inferenz.
Vue 3
Kernkonzepte im Überblick
- setup() — Einstiegspunkt der Composition API, läuft vor dem Rendering
- ref() — Reaktiver Wrapper für primitive Werte (number, string, boolean)
- reactive() — Reaktives Objekt für komplexe Datenstrukturen
- computed() — Gecachte, abgeleitete reaktive Werte
- watch() / watchEffect() — Side Effects bei Änderungen
ref und reactive im Vergleich
ref eignet sich für einzelne Werte und braucht im Template kein .value.
reactive ist für Objekte gedacht, erlaubt aber kein Destructuring ohne
Reaktivitätsverlust.
<!-- setup sugar syntax (empfohlen) -->
<script setup lang="ts">
import { ref, reactive, computed, watch, watchEffect } from 'vue'
// ref: primitiver Wert
const count = ref<number>(0)
const username = ref<string>('')
// reactive: Objekt
const user = reactive({
name: '',
email: '',
role: 'viewer' as 'viewer' | 'editor' | 'admin'
})
// computed: gecacht bis abhängige refs ändern
const doubleCount = computed(() => count.value * 2)
const isAdmin = computed(() => user.role === 'admin')
// watch: gezielt auf bestimmte Source reagieren
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
})
// watchEffect: läuft sofort + bei jeder Dependency-Änderung
watchEffect(() => {
document.title = `Zähler: ${count.value}`
})
function increment() { count.value++ }
function reset() { count.value = 0 }
</script>
<template>
<div>
<p>Count: {{ count }} | Double: {{ doubleCount }}</p>
<button @click="increment">+1</button>
<button @click="reset">Reset</button>
</div>
</template>
TypeScript-Typen in Vue 3
Vue 3 nutzt TypeScript nativ. defineProps und defineEmits werden
direkt mit Typen annotiert — kein separates PropType-Cast mehr nötig.
<script setup lang="ts">
// Props mit TypeScript-Interface
interface Props {
userId: number
username: string
role?: 'viewer' | 'editor' | 'admin'
verified?: boolean
}
const props = withDefaults(defineProps<Props>(), {
role: 'viewer',
verified: false
})
// Emits mit Typen
const emit = defineEmits<{
(e: 'update:role', role: Props['role']): void
(e: 'delete', userId: number): void
}>()
function promoteToAdmin() {
emit('update:role', 'admin')
}
</script>
<template>
<div class="user-card">
<h3>{{ props.username }}</h3>
<span>{{ props.role }}</span>
<button @click="promoteToAdmin">Admin machen</button>
<button @click="emit('delete', props.userId)">Löschen</button>
</div>
</template>
Claude Code Tipp: Schreib // @claude: generiere TypeScript Interface für User aus dieser JSON-Struktur in dein Vue-File — Claude Code ergänzt vollständige Typdefinitionen inkl. optionaler Felder.
Lifecycle Hooks in setup()
Alle Lifecycle Hooks stehen als importierbare Funktionen zur Verfügung. Sie können mehrfach
registriert und modular in Composables verteilt werden.
import {
onMounted, onUnmounted, onBeforeMount, onUpdated,
onBeforeUpdate, onActivated, onDeactivated
} from 'vue'
const cleanup: (() => void)[] = []
onMounted(() => {
const handler = () => console.log('resize')
window.addEventListener('resize', handler)
cleanup.push(() => window.removeEventListener('resize', handler))
})
onUnmounted(() => {
cleanup.forEach(fn => fn())
})
onUpdated(() => {
console.log('Komponente aktualisiert')
})
2. Composables: Logik kapseln und wiederverwenden
Composables sind Funktionen, die die Composition API nutzen, um wiederverwendbare
Logik zu kapseln. Sie ersetzen Mixins vollständig und ermöglichen Tree-Shaking, vollständige
Typsicherheit und einfaches Testen.
Composable
Naming-Konvention: use* Präfix
Composables beginnen per Konvention mit use: useCounter,
useFetch, useLocalStorage, useDebounce.
Der Präfix signalisiert, dass reactive State zurückgegeben wird.
useCounter — Einfaches Composable
import { ref, computed, Ref } from 'vue'
interface UseCounterOptions {
min?: number
max?: number
step?: number
}
interface UseCounterReturn {
count: Ref<number>
isMin: Ref<boolean>
isMax: Ref<boolean>
increment: () => void
decrement: () => void
reset: () => void
}
export function useCounter(
initialValue = 0,
options: UseCounterOptions = {}
): UseCounterReturn {
const { min = -Infinity, max = Infinity, step = 1 } = options
const count = ref(initialValue)
const isMin = computed(() => count.value <= min)
const isMax = computed(() => count.value >= max)
function increment() {
if (count.value + step <= max) count.value += step
}
function decrement() {
if (count.value - step >= min) count.value -= step
}
function reset() { count.value = initialValue }
return { count, isMin, isMax, increment, decrement, reset }
}
useFetch — Daten asynchron laden
import { ref, shallowRef, MaybeRef, toValue } from 'vue'
import { watchEffect } from 'vue'
export function useFetch<T>(url: MaybeRef<string>) {
const data = shallowRef<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
watchEffect(async () => {
data.value = null
error.value = null
loading.value = true
try {
const resolvedUrl = toValue(url)
const res = await fetch(resolvedUrl)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json() as T
} catch (e) {
error.value = e instanceof Error ? e : new Error('Unbekannter Fehler')
} finally {
loading.value = false
}
})
return { data, error, loading }
}
// Verwendung:
// const { data, loading, error } = useFetch<User[]>('/api/users')
useLocalStorage — Reaktiver Browser-Speicher
import { ref, watch } from 'vue'
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = localStorage.getItem(key)
const initial: T = stored
? JSON.parse(stored) as T
: defaultValue
const value = ref<T>(initial) as Ref<T>
watch(value, (newVal) => {
localStorage.setItem(key, JSON.stringify(newVal))
}, { deep: true })
return value
}
// Verwendung:
// const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
provide / inject — Dependency Injection
// Parent.vue
import { provide } from 'vue'
import type { InjectionKey } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
const theme = ref<'light' | 'dark'>('dark')
provide(ThemeKey, theme)
// Child.vue (beliebig tief verschachtelt)
import { inject } from 'vue'
import { ThemeKey } from '../Parent.vue'
const theme = inject(ThemeKey) // Typ: Ref<'light' | 'dark'> | undefined
3. Pinia: defineStore im Detail
Pinia ist der offizielle State-Manager für Vue 3 und löst Vuex ab. Es bietet
vollständige TypeScript-Inferenz ohne Boilerplate, modulare Stores und native DevTools-Unterstützung.
Kein Mutations-Overhead
State direkt mutieren — kein Vuex-Mutations-Boilerplate mehr.
TypeScript-First
Vollständige Typ-Inferenz für State, Getters und Actions ohne extra Config.
DevTools-Integration
Vue DevTools zeigt Store-State, Time-Travel und Action-History.
Modular & Tree-Shakeable
Jeder Store ist ein eigenständiges Modul. Ungenutzte Stores landen nicht im Bundle.
Pinia
Installation
npm install pinia
# Optional: Persistenz-Plugin
npm install pinia-plugin-persistedstate
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
Options Store vs. Setup Store
Pinia bietet zwei Syntaxvarianten. Der Setup Store (empfohlen) nutzt direkt die Composition API:
| Aspekt |
Options Store |
Setup Store (empfohlen) |
| Syntax |
{ state, getters, actions } |
Composition API: ref, computed |
| TypeScript |
Explizite Typen nötig |
Vollständige Inferenz |
| Composables |
Eingeschränkt nutzbar |
Direkt verwendbar |
| Lernkurve |
Flach (Vuex-ähnlich) |
Etwas steiler |
| Empfehlung 2026 |
Legacy / Migration |
Neue Projekte |
Options Store — Klassische Variante
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
role: 'viewer' | 'editor' | 'admin'
}
interface UserState {
currentUser: User | null
users: User[]
loading: boolean
}
export const useUserStoreOptions = defineStore('users-options', {
state: (): UserState => ({
currentUser: null,
users: [],
loading: false
}),
getters: {
isLoggedIn: (state) => state.currentUser !== null,
adminUsers: (state) => state.users.filter(u => u.role === 'admin'),
userCount: (state) => state.users.length
},
actions: {
async fetchUsers() {
this.loading = true
try {
const res = await fetch('/api/users')
this.users = await res.json()
} finally {
this.loading = false
}
}
}
})
Setup Store — Empfohlene Variante
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
role: 'viewer' | 'editor' | 'admin'
createdAt: string
}
export const useUserStore = defineStore('users', () => {
// State: refs
const currentUser = ref<User | null>(null)
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Getters: computed
const isLoggedIn = computed(() => currentUser.value !== null)
const adminUsers = computed(() => users.value.filter(u => u.role === 'admin'))
const userCount = computed(() => users.value.length)
// Actions: async functions
async function fetchUsers() {
loading.value = true
error.value = null
try {
const res = await fetch('/api/users')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
users.value = await res.json() as User[]
} catch (e) {
error.value = e instanceof Error ? e.message : 'Fehler beim Laden'
} finally {
loading.value = false
}
}
async function login(email: string, password: string) {
loading.value = true
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
currentUser.value = await res.json() as User
} finally {
loading.value = false
}
}
function logout() { currentUser.value = null }
// Exportiere alles
return {
currentUser, users, loading, error,
isLoggedIn, adminUsers, userCount,
fetchUsers, login, logout
}
})
4. storeToRefs & Actions
Der häufigste Anfängerfehler mit Pinia: Store destrukturieren und dabei die Reaktivität verlieren.
storeToRefs löst dieses Problem elegant.
Achtung: Niemals Store-State direkt destrukturieren! const { users } = useUserStore() bricht die Reaktivität — users ist dann ein normales Array, keine reactive Ref.
storeToRefs korrekt verwenden
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '../stores/userStore'
const userStore = useUserStore()
// RICHTIG: storeToRefs für State + Getters
const { users, loading, error, isLoggedIn, adminUsers } = storeToRefs(userStore)
// Actions direkt vom Store (keine storeToRefs nötig)
const { fetchUsers, login, logout } = userStore
// Wird korrekt reaktiv aufgerufen
fetchUsers()
</script>
<template>
<div>
<div v-if="loading">Lädt...</div>
<div v-else-if="error">{{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }} — {{ user.role }}
</li>
</ul>
<p>Admins: {{ adminUsers.length }}</p>
</div>
</template>
$patch — Gebündelter State-Update
Mit $patch können mehrere State-Felder atomisch aktualisiert werden. Als Objekt
oder als Callback-Funktion.
const store = useUserStore()
// Objekt-Variante: nur geänderte Felder angeben
store.$patch({
loading: false,
error: null
})
// Callback-Variante: für komplexe Mutationen (z.B. Arrays)
store.$patch((state) => {
state.users.push({ id: 99, name: 'Test', email: 't@t.de', role: 'viewer', createdAt: '' })
state.loading = false
})
$reset — State zurücksetzen
// Nur bei Options Store direkt verfügbar
store.$reset()
// Setup Store: $reset manuell implementieren
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))
function $reset() {
items.value = []
}
return { items, total, $reset }
})
$subscribe — State-Änderungen beobachten
const cartStore = useCartStore()
// Subscription: läuft bei jedem State-Change
cartStore.$subscribe((mutation, state) => {
console.log('Cart geändert:', mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log('Neue Items:', state.items.length)
// Beispiel: in Analytics senden
analytics.track('cart_updated', { itemCount: state.items.length })
}, {
detached: true // weiter laufen auch wenn Komponente unmounted
})
// Action-Subscription
cartStore.$onAction(({ name, args, after, onError }) => {
console.log(`Action "${name}" gestartet`, args)
after((result) => {
console.log(`Action "${name}" beendet`, result)
})
onError((error) => {
console.error(`Action "${name}" fehlgeschlagen`, error)
})
})
Claude Code Tipp: Öffne deinen Store in Claude Code und schreib // @claude: füge $onAction Logging für alle Actions hinzu — Claude Code implementiert vollständiges Action-Tracking inkl. Error-Handling und Performance-Messung.
5. Pinia Persistenz
pinia-plugin-persistedstate ist das Standard-Plugin für Store-Persistenz in Vue 3.
Es serialisiert automatisch State in localStorage oder sessionStorage und hydratisiert ihn beim
App-Start.
Plugin einrichten
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
createApp(App)
.use(pinia)
.mount('#app')
Einfache Persistenz aktivieren
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useSettingsStore = defineStore('settings', () => {
const theme = ref<'light' | 'dark'>('dark')
const language = ref<'de' | 'en'>('de')
const notifications = ref(true)
return { theme, language, notifications }
}, {
persist: true // kompletter Store in localStorage
})
Selektive Persistenz — nur bestimmte Felder
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
const userId = ref<number | null>(null)
const sessionData = ref<Record<string, unknown>>({}) // NICHT persistieren
const isAuthenticated = computed(() => !!accessToken.value)
async function refreshAuth() {
if (!refreshToken.value) return
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Authorization': `Bearer ${refreshToken.value}` }
})
const { access_token } = await res.json()
accessToken.value = access_token
}
return { accessToken, refreshToken, userId, sessionData, isAuthenticated, refreshAuth }
}, {
persist: {
key: 'auth-store',
storage: localStorage,
pick: ['accessToken', 'refreshToken', 'userId'] // sessionData wird NICHT persistiert
}
})
Custom Serializer — sichere Token-Verschlüsselung
import { CryptoES } from 'crypto-es'
const SECRET = import.meta.env.VITE_STORAGE_SECRET
export const useSecureStore = defineStore('secure', () => {
const sensitiveData = ref<string[]>([])
return { sensitiveData }
}, {
persist: {
serializer: {
serialize: (value) => CryptoES.AES.encrypt(JSON.stringify(value), SECRET).toString(),
deserialize: (value) => JSON.parse(CryptoES.AES.decrypt(value, SECRET).toString(CryptoES.enc.Utf8))
}
}
})
Pinia
SessionStorage für temporäre Daten
Für Warenkorb oder Wizard-States: sessionStorage löscht Daten beim Tab-Schließen.
persist: {
storage: sessionStorage,
pick: ['cartItems', 'wizardStep']
}
6. Nuxt 3 Integration
Nuxt 3 ist das Full-Stack-Framework auf Vue-3-Basis. Es bringt SSR, SSG,
Server Routes und Auto-Imports out of the box. Pinia wird als offiziell unterstütztes
State-Management integriert.
Nuxt 3
Pinia in Nuxt 3 einrichten
npx nuxi init my-app
cd my-app
npm install @pinia/nuxt pinia pinia-plugin-persistedstate
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
storesDirs: ['./stores/**'] // auto-import Stores
}
})
useState — Nuxt's reaktiver Server-State
useState ist Nuxt's eingebautes Composable für SSR-sicheren State. State wird zwischen
Server und Client automatisch synchronisiert (Hydration).
// composables/useAppState.ts
export const useAppState = () => {
const counter = useState<number>('counter', () => 0)
const theme = useState<'light' | 'dark'>('theme', () => 'dark')
function increment() { counter.value++ }
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
return { counter, theme, increment, toggleTheme }
}
useFetch und useAsyncData — Datenfetching mit SSR
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
// useFetch: automatisches SSR + Client-Caching
const { data: users, pending, error, refresh } = await useFetch<User[]>(
'/api/users',
{
key: 'users-list',
transform: (data) => data.sort((a, b) => a.name.localeCompare(b.name))
}
)
// useAsyncData: für komplexere Fetch-Logik
const { data: stats } = await useAsyncData('user-stats', async () => {
const [total, active] = await Promise.all([
$fetch<number>('/api/users/count'),
$fetch<number>('/api/users/active-count')
])
return { total, active, inactive: total - active }
})
</script>
<template>
<div>
<div v-if="pending">Lädt 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 }}</li>
</ul>
<button @click="refresh()">Neu laden</button>
<p v-if="stats">{{ stats.active }} / {{ stats.total }} aktiv</p>
</div>
</template>
Server Components in Nuxt 3
Mit dem .server.vue-Suffix werden Komponenten ausschließlich auf dem Server gerendert.
Perfekt für datenintensive Bereiche ohne Client-JS-Overhead.
<!-- components/HeavyChart.server.vue -->
<!-- Läuft NUR auf dem Server — kein JavaScript im Browser! -->
<script setup lang="ts">
const { data: chartData } = await useFetch('/api/chart-data')
</script>
<template>
<div class="chart">
<!-- statisches HTML, kein Hydrationsbedarf -->
<svg v-for="point in chartData" :key="point.id">...</svg>
</div>
</template>
Pinia Store in Nuxt 3 mit Persistenz
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface CartItem {
id: number
name: string
price: number
quantity: number
}
// In Nuxt 3: Auto-Import wenn in stores/ Verzeichnis
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const itemCount = computed(() =>
items.value.reduce((acc, item) => acc + item.quantity, 0)
)
const total = computed(() =>
items.value.reduce((acc, item) => acc + item.price * item.quantity, 0)
)
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.id === item.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...item, quantity: 1 })
}
}
function removeItem(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
function clearCart() { items.value = [] }
return { items, itemCount, total, addItem, removeItem, clearCart }
}, {
persist: {
storage: typeof window !== 'undefined' ? localStorage : null,
pick: ['items'] // SSR-sicher: nur client-side persistieren
}
})
Claude Code + Nuxt: Mit // @claude: generiere Server Route für /api/users mit Supabase und TypeScript erstellt Claude Code vollständige Nuxt Server Routes inkl. Fehlerbehandlung, Zod-Validation und TypeScript-Typen — in unter einer Minute.
Claude Code als Vue-Entwicklungspartner
Claude Code versteht Vue 3, Pinia und Nuxt 3 auf Experten-Niveau. Typische Workflows, die in
Minuten statt Stunden erledigt sind:
- Komplette Pinia-Stores aus API-Dokumentation generieren
- Options-Store zu Setup-Store migrieren mit vollständiger TypeScript-Inferenz
- Composables aus Komponenten extrahieren und testen
- Nuxt 3 Server Routes mit Validierung und Fehlerbehandlung erstellen
- Pinia-Persistenz-Konfiguration für komplexe SSR-Szenarien aufsetzen
- DevTools-Integration und Debugging-Utilities generieren
Vue 3 & Pinia mit KI beschleunigen
Teste Claude Code kostenlos und erlebe, wie schnell du Vue-3-Projekte mit KI-Unterstützung
realisierst — von defineStore bis zur Nuxt-3-Deployment-Pipeline.
Kostenlos starten — kein Kreditkarte nötig