Pinia is the official Vue 3 state management library — defineStore("id", () => { ... }) uses the setup function syntax returning state refs and actions. const { count } = storeToRefs(useCounterStore()) destructures with preserved reactivity. store.$patch({ key: value }) batches multiple state updates atomically. store.$reset() restores initial state. store.$subscribe((mutation, state) => ...) watches state changes. store.$onAction(({ name, after, onError }) => ...) hooks into action calls. Cross-store composition works by calling useOtherStore() inside the setup function. Pinia plugins add properties via pinia.use(plugin) — pinia-plugin-persistedstate persists state to localStorage. defineStore("id", { state, getters, actions }) is the options API alternative. Vue DevTools shows the Pinia panel for time-travel debugging. SSR hydration uses initialState for server-rendered state. Claude Code generates Pinia cart stores, user auth stores, filter stores, optimistic UI patterns, and persistent preference stores.
CLAUDE.md for Pinia
## Pinia Stack
- Version: pinia >= 2.2, pinia-plugin-persistedstate >= 3.2
- Setup: const useCartStore = defineStore("cart", () => { const items = ref<CartItem[]>([]); ... return { items, ... } })
- Access: const store = useCartStore(); const { items } = storeToRefs(store) — preserves reactivity
- Patch: store.$patch({ loading: false, items: [] }) — batch update
- Reset: store.$reset() — options API only; setup stores need manual reset action
- Persist: { persist: { key: "cart", storage: localStorage } } — via pinia-plugin-persistedstate
- Plugin: app.use(createPinia().use(piniaPluginPersistedstate))
Cart Store
// stores/cart.ts — shopping cart with Pinia setup syntax
import { defineStore } from "pinia"
import { ref, computed } from "vue"
export type CartItem = {
id: string
productId: string
name: string
price: number // cents
quantity: number
imageUrl: string
variantId?: string
}
export const useCartStore = defineStore(
"cart",
() => {
// State
const items = ref<CartItem[]>([])
const couponCode = ref<string | null>(null)
const isOpen = ref(false)
// Getters
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0),
)
const subtotalCents = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0),
)
const discountCents = computed(() => {
if (couponCode.value === "SAVE10") return Math.round(subtotalCents.value * 0.1)
return 0
})
const totalCents = computed(() =>
Math.max(0, subtotalCents.value - discountCents.value),
)
const isEmpty = computed(() => items.value.length === 0)
// Actions
function addItem(newItem: Omit<CartItem, "quantity">, quantity = 1) {
const existing = items.value.find(
i => i.productId === newItem.productId && i.variantId === newItem.variantId,
)
if (existing) {
existing.quantity += quantity
} else {
items.value.push({ ...newItem, quantity })
}
}
function removeItem(itemId: string) {
const idx = items.value.findIndex(i => i.id === itemId)
if (idx !== -1) items.value.splice(idx, 1)
}
function updateQuantity(itemId: string, quantity: number) {
const item = items.value.find(i => i.id === itemId)
if (!item) return
if (quantity <= 0) {
removeItem(itemId)
} else {
item.quantity = quantity
}
}
function applyCoupon(code: string) {
const valid = ["SAVE10", "WELCOME20"]
if (!valid.includes(code.toUpperCase())) {
throw new Error("Invalid coupon code")
}
couponCode.value = code.toUpperCase()
}
function clearCoupon() {
couponCode.value = null
}
function clear() {
items.value = []
couponCode.value = null
}
function toggle() {
isOpen.value = !isOpen.value
}
return {
// State
items,
couponCode,
isOpen,
// Getters
itemCount,
subtotalCents,
discountCents,
totalCents,
isEmpty,
// Actions
addItem,
removeItem,
updateQuantity,
applyCoupon,
clearCoupon,
clear,
toggle,
}
},
{
// Persist via pinia-plugin-persistedstate
persist: {
key: "cart-v2",
storage: typeof window !== "undefined" ? localStorage : undefined,
pick: ["items", "couponCode"], // Don't persist UI state
},
},
)
Auth Store with Cross-Store Dependency
// stores/auth.ts — user auth + cross-store cart merge
import { defineStore } from "pinia"
import { ref, computed } from "vue"
import { useCartStore } from "./cart"
export type User = {
id: string
name: string
email: string
role: "user" | "admin"
avatarUrl: string | null
}
export const useAuthStore = defineStore("auth", () => {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => user.value !== null)
const isAdmin = computed(() => user.value?.role === "admin")
async function login(email: string, password: string) {
loading.value = true
error.value = null
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error ?? "Login failed")
}
const { user: userData } = await res.json()
user.value = userData
// Merge guest cart into user cart
const cartStore = useCartStore()
if (!cartStore.isEmpty) {
await fetch("/api/cart/merge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: cartStore.items }),
})
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Unknown error"
throw err
} finally {
loading.value = false
}
}
async function logout() {
await fetch("/api/auth/logout", { method: "POST" })
user.value = null
const cartStore = useCartStore()
cartStore.clear()
}
async function fetchCurrentUser() {
loading.value = true
try {
const res = await fetch("/api/auth/me")
if (res.ok) {
user.value = await res.json()
}
} finally {
loading.value = false
}
}
return {
user,
loading,
error,
isAuthenticated,
isAdmin,
login,
logout,
fetchCurrentUser,
}
})
Vue Component Usage
<!-- components/CartDrawer.vue — cart UI with Pinia -->
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useCartStore } from "@/stores/cart"
const cart = useCartStore()
// storeToRefs preserves reactivity when destructuring
const { items, itemCount, subtotalCents, totalCents, discountCents, isOpen } = storeToRefs(cart)
function formatCents(cents: number) {
return (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD" })
}
</script>
<template>
<Transition name="slide">
<div v-if="isOpen" class="fixed right-0 top-0 h-full w-96 bg-background border-l shadow-xl z-50 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<h2 class="font-semibold text-lg">Cart ({{ itemCount }})</h2>
<button @click="cart.toggle()" class="p-1 rounded hover:bg-muted">✕</button>
</div>
<!-- Items -->
<div class="flex-1 overflow-y-auto p-4 space-y-3">
<div v-if="cart.isEmpty" class="text-center text-muted-foreground py-12">
Your cart is empty
</div>
<div v-for="item in items" :key="item.id" class="flex gap-3 p-3 rounded-xl border">
<img :src="item.imageUrl" :alt="item.name" class="size-16 object-cover rounded-lg" />
<div class="flex-1 min-w-0">
<p class="font-medium text-sm truncate">{{ item.name }}</p>
<p class="text-muted-foreground text-sm">{{ formatCents(item.price) }}</p>
<div class="flex items-center gap-2 mt-1">
<button @click="cart.updateQuantity(item.id, item.quantity - 1)" class="size-6 rounded border text-sm">-</button>
<span class="text-sm w-6 text-center">{{ item.quantity }}</span>
<button @click="cart.updateQuantity(item.id, item.quantity + 1)" class="size-6 rounded border text-sm">+</button>
</div>
</div>
<button @click="cart.removeItem(item.id)" class="text-muted-foreground hover:text-destructive text-sm">✕</button>
</div>
</div>
<!-- Footer -->
<div v-if="!cart.isEmpty" class="p-4 border-t space-y-3">
<div v-if="discountCents > 0" class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{{ formatCents(discountCents) }}</span>
</div>
<div class="flex justify-between font-semibold">
<span>Total</span>
<span>{{ formatCents(totalCents) }}</span>
</div>
<a href="/checkout" class="btn-primary w-full text-center block">
Checkout
</a>
</div>
</div>
</Transition>
</template>
For the Vuex alternative when a large Vue 2 codebase with existing Vuex modules needs state management — Pinia is the official successor to Vuex and should be used for all new Vue 3 projects, while Vuex 4 is maintained only for Vue 3 migration compatibility, see the Vuex guide. For the Zustand with React alternative when building the same feature in a React application — Pinia is Vue-ecosystem only while Zustand fills the same role in React with a similar lightweight, non-opinionated API, see the Zustand guide. The Claude Skills 360 bundle includes Pinia skill sets covering store design, cross-store composition, and persistence. Start with the free tier to try Vue state management generation.