Appearance
State Management
This guide codifies how we structure and use Pinia to manage client-side state in Vue 3 applications, keeping conventions consistent across modules.
Scope: Application/client state only. Server/API state is managed by the Query/Mutation layer. Don't store API lists/detail payloads in Pinia unless you have a compelling client-state reason.
Convention
- One store per module/domain:
- Path:
src/stores/<MODULE>/<module>.store.ts - Name:
use<Module>Store; Pinia id:<module>Storein camelCase (e.g.,cartStore, hookuseCartStore).
- Path:
- Register the Pinia id in
PiniaStoreIdenum (src/config/pinia.ts); enum members use PascalCase and string values use camelCase.
ts
// src/config/pinia.ts
export enum PiniaStoreId {
Cart = 'cartStore',
Auth = 'authStore',
Prefs = 'prefsStore',
Products = 'productsStore',
}- Keep it cohesive: state + getters + actions that belong together.
- Extract heavy logic into pure functions in
src/services/<MODULE>/…, then call from store actions. - Use Setup Stores by default (Composition API) for maximum flexibility.
- Typing: declare explicit state interfaces or type aliases (exported, prefixed with
State*) and return a typed API surface.- Shared/domain shapes live in
src/models/(e.g.,User,Product). - Store-only interfaces stay colocated with the store file; skip global
stores/models/barrels.
- Shared/domain shapes live in
- Keep actions pure: allow async side-effects, but delegate core transformations to pure service functions.
- No index barrels in
stores/—import stores directly. - Compose stores thoughtfully: call other stores from actions/getters and avoid circular reads in
setup().
ts
// from another store's action
import { useCartStore } from '@/stores/cart/cart.store'
function checkout() {
const cart = useCartStore()
if (!cart.hasItems) return
// avoid circular reads: don't call this store back from cart's setup()
}Template (TypeScript, Setup Store)
ts
// src/stores/cart/cart.store.ts
import { defineStore } from 'pinia'
import { PiniaStoreId } from '@/config/pinia'
export interface CartItemState { id: number; name: string; qty: number }
export const useCartStore = defineStore(PiniaStoreId.Cart, () => {
// state
const items = ref<CartItemState[]>([])
const isOpen = ref(false)
// getters (computed)
const count = computed(() => items.value.length)
const hasItems = computed(() => count.value > 0)
// actions
function add(item: CartItemState) { items.value.push(item) }
function clear() { items.value = [] }
function toggle() { isOpen.value = !isOpen.value }
return { items, isOpen, count, hasItems, add, clear, toggle }
})Using in Components
In components, don't destructure the store directly (breaks reactivity). Use
storeToRefs(store)for state/getters; destructure actions from the store itself.
ts
import { storeToRefs } from 'pinia'
const cart = useCartStore()
const { items, hasItems, count } = storeToRefs(cart) // reactive
const { add, clear } = cart // actions OK to destructureSetup
- Centralize Pinia creation and plugin registration in
src/stores/index.ts, then install it frommain.ts.
ts
// src/stores/index.ts
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persist)
export default piniats
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import store from '@/stores'
const app = createApp(App)
app.use(store)
app.mount('#app')Persistence
- Persist only what's safe and necessary (e.g., UI preferences, cart, feature toggles).
- Never persist secrets, tokens, or PII — see Anti-patterns.
- Use
pinia-plugin-persistedstateto persist and rehydrate automatically.
ts
// src/stores/prefs/prefs.store.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { PiniaStoreId } from '@/config/pinia'
export type ThemeState = 'light' | 'dark'
export const usePrefsStore = defineStore(PiniaStoreId.Prefs, () => {
const theme = ref<ThemeState>('light')
return { theme }
}, { persist: { paths: ['theme'], storage: localStorage } })Subscriptions, actions & patching
- Use
store.$subscribeto react to state changes (e.g., manual persistence, analytics). - Use
store.$onActionto observe/trace actions and errors. - Prefer
store.$patch()for partial updates / batched mutations. - Both return unsubscribe functions—tie them to
onUnmounted(or equivalent) to avoid leaks.
ts
import { onUnmounted } from 'vue'
const stop = cart.$onAction(({ name, after, onError }) => {
const start = performance.now()
after(() => console.debug(`[cart] ${name} in ${performance.now() - start}ms`))
onError((err) => console.error(`[cart] ${name} error`, err))
})
onUnmounted(stop)ts
// batch partial updates
cart.$patch({ isOpen: false })
// or with a function for complex updates
cart.$patch((state) => {
state.items.push({ id: 99, name: 'Mouse', qty: 1 })
})Testing
Test store logic via actions/getters; mock services instead of Pinia internals.
ts
// CartStore.spec.ts
import { beforeEach, expect, test } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useCartStore } from './cart.store'
beforeEach(() => {
setActivePinia(createPinia())
})
test('adds items', () => {
const cart = useCartStore()
cart.add({ id: 1, name: 'Keyboard', qty: 1 })
expect(cart.count).toBe(1)
expect(cart.items[0].name).toBe('Keyboard')
})Decision table (quick)
| Scenario | Use |
|---|---|
| Toggle UI, wizard step, drawer open | Pinia store |
| Persisted user preference (theme, lang) | Pinia store + persistedstate plugin |
| Entity list/detail, pagination, filters | Query/Mutation layer |
| Cache-aware invalidation & refetch | Query/Mutation layer |
| Complex transformation of payloads | Service functions called by store |
What goes in Pinia (vs the Query/Mutation layer)
- Pinia (client state): UI flags (modals, menus), draft inputs, client-only preferences, cross-component state, ephemeral session info.
- Query/Mutation layer (server state): entity lists/details, paginated/filtered resources, mutations with cache invalidation.
Anti-patterns to avoid
- Persisting secrets, auth tokens, or PII in Pinia/browser storage—delegate to secure storage flows.
ts
import { PiniaStoreId } from '@/config/pinia'
// ❌ BAD: Pinia persistence containing tokens
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
const accessToken = ref('')
function signIn(payload: SignInResponse) {
accessToken.value = payload.accessToken
}
return { accessToken, signIn }
}, { persist: true })
// ✅ GOOD: server handles secure storage
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
const profile = ref<UserProfile | null>(null)
async function signIn(credentials: Credentials) {
const data = await authService.signIn(credentials) // backend sets httpOnly cookie
profile.value = data.profile
}
return { profile, signIn }
})- Mirroring server collections in Pinia; keep canonical data in the Query/Mutation layer and derive client state from it.
ts
import { PiniaStoreId } from '@/config/pinia'
// ❌ BAD: duplicating server data in Pinia
export const useProductsStore = defineStore(PiniaStoreId.Products, () => {
const products = ref<Product[]>([])
async function load() {
products.value = await api.fetchProducts()
}
return { products, load }
})
// ✅ GOOD: Pinia stores view state only
export const useProductsStore = defineStore(PiniaStoreId.Products, () => {
const selectedProductId = ref<number | null>(null)
const productsQuery = useProductsQuery()
const selectedProduct = computed(() =>
productsQuery.data.value?.find((product) => product.id === selectedProductId.value) ?? null
)
return { selectedProductId, selectedProduct }
})- Injecting router instances or global singletons into store state—pass them through actions or services instead.
ts
import { PiniaStoreId } from '@/config/pinia'
// ❌ BAD: storing router in Pinia
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
const router = useRouter()
function signOut() {
router.push('/login')
}
return { signOut }
})
// ✅ GOOD: accept dependencies in calls
export const useAuthStore = defineStore(PiniaStoreId.Auth, () => {
function signOut(router: Router) {
router.push('/login')
}
return { signOut }
})Rationale
- Vue 3 setup stores keep state colocated with behavior while staying testable and type-safe.
- Separating client and server state responsibilities prevents duplicated caches and clarifies data ownership.
- Strong typing, service extraction, and testing patterns reduce regression risk as modules scale.