
Pinia V3
- 137 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Helps with ai & agent building tasks during AI-assisted development.
About
pinia-v3 is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- pinia-v3
- AI & Agent Building
- AI-coding skill
Pinia V3 by the numbers
- 137 all-time installs (skills.sh)
- +10 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,529 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill pinia-v3Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 137 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Pinia v3 - Vue State Management
Status: Production Ready ✅ Last Updated: 2025-11-11 Dependencies: Vue 3 (or Vue 2.7 with @vue/composition-api) Latest Versions: pinia@^3.0.4, @pinia/nuxt@^0.11.2, @pinia/testing@^1.0.2
---
Quick Start (5 Minutes)
1. Install Pinia
bun add pinia
# or
bun add pinia
# or
bun add piniaFor Vue <2.7 users: Also install @vue/composition-api with bun add @vue/composition-api
Why this matters:
- Pinia is the official Vue state management library
- Provides better TypeScript support than Vuex
- Eliminates mutations and namespacing complexity
- Full DevTools support with time-travel debugging
2. Create and Register Pinia Instance
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')CRITICAL:
- Install Pinia BEFORE using any store
- Call
app.use(pinia)before mounting the app - Only one Pinia instance per application (unless SSR)
3. Define Your First Store
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo'
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})4. Use Store in Components
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<div>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<button @click="counter.increment">Increment</button>
</div>
</template>---
The Two Store Syntaxes
Load `references/store-syntax-guide.md` for complete comparison of Option vs Setup stores.
Quick Overview
Pinia supports two store definition syntaxes:
Option Stores:
- Similar to Vue Options API
- Built-in
$reset()method - Best for: Simpler use cases, teams familiar with Vuex
Setup Stores:
- Uses Composition API pattern
- Full composables integration
- Best for: Advanced patterns, need watchers/VueUse integration
→ Load `references/store-syntax-guide.md` for: Complete syntax comparison, examples, choosing criteria
---
State, Getters, and Actions
Load `references/state-getters-actions.md` for complete API reference.
Quick Reference
State:
- Define in
state: () => ({...})(option) orref()(setup) - Access directly:
store.count - Mutate directly:
store.count++orstore.$patch({...}) - Reset:
store.$reset()(option stores only)
Getters:
- Computed properties:
getters: { double: (state) => state.count * 2 } - Access other getters with
this(must type return value)
Actions:
- Business logic:
actions: { increment() { this.count++ } } - Can be async
- Access other stores directly
Store Destructuring:
import { storeToRefs } from 'pinia'
// ✅ For reactivity
const { name, count } = storeToRefs(store)
// ✅ Actions can destructure directly
const { increment } = store→ Load `references/state-getters-actions.md` for: Complete API, subscriptions, store composition patterns, Options API usage
---
Plugins and Composables
Load `references/plugins-composables.md` for complete plugin and composables guide.
Plugin Basics
pinia.use(({ store, options }) => {
// Add properties to every store
return { customProperty: 'value' }
})Composables Integration
Option Stores: Limited to useLocalStorage style in state() Setup Stores: Full VueUse/composables support
→ Load `references/plugins-composables.md` for: Complete plugin patterns, VueUse integration, TypeScript typing, common patterns (persistence, router, logger)
---
Using Stores Outside Components
The Problem
Stores need the Pinia instance, which is auto-injected in components but not available in module scope.
❌ Wrong: Accessing Store at Module Level
// router.ts
import { useUserStore } from '@/stores/user'
// ❌ Fails: Pinia not installed yet
const userStore = useUserStore()
router.beforeEach((to) => {
if (userStore.isLoggedIn) { /* ... */ }
})✅ Right: Accessing Store Inside Callbacks
// router.ts
import { useUserStore } from '@/stores/user'
router.beforeEach((to) => {
// ✅ Works: Called after Pinia is installed
const userStore = useUserStore()
if (userStore.isLoggedIn) { /* ... */ }
})Why it works: Router guards execute AFTER app.use(pinia) completes.
SSR: Explicit Pinia Instance
// server-side
export function setupRouter(pinia) {
router.beforeEach((to) => {
const userStore = useUserStore(pinia) // Pass explicitly
})
}---
Server-Side Rendering & Nuxt
Load `references/ssr-and-nuxt.md` for complete SSR and Nuxt integration guide.
SSR Quick Reference
State Hydration:
- Server: Serialize with
devalue()(notJSON.stringify) - Client: Hydrate BEFORE calling
useStore() - Critical: Call all
useStore()BEFOREawaitin actions
Nuxt 3/4 Integration
bunx nuxi@latest module add piniaAuto-imports: defineStore, storeToRefs, usePinia, acceptHMRUpdate, all stores
→ Load `references/ssr-and-nuxt.md` for: Complete SSR patterns, Nuxt configuration, server-side data fetching, SSR pitfalls, debugging
---
Testing
Load `references/testing-guide.md` for complete testing guide.
Testing Quick Start
import { setActivePinia, createPinia } from 'pinia'
beforeEach(() => {
setActivePinia(createPinia()) // Fresh Pinia for each test
})Component Testing
bun add -d @pinia/testingimport { createTestingPinia } from '@pinia/testing'
mount(Component, {
global: { plugins: [createTestingPinia()] }
})→ Load `references/testing-guide.md` for: Complete test patterns, stubbing actions, mocking getters, async testing, SSR testing
---
Hot Module Replacement (HMR)
Vite Setup
// stores/counter.ts
import { defineStore, acceptHMRUpdate } from 'pinia'
export const useCounterStore = defineStore('counter', {
// store definition
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}Webpack Setup
if (import.meta.webpackHot) {
import.meta.webpackHot.accept(acceptHMRUpdate(useCounterStore, import.meta.webpackHot))
}Benefits:
- Edit stores without full page reload
- Preserve application state during development
- Faster development iteration
---
Options API Usage
For projects still using Options API, load complete mapper documentation.
→ Load `references/state-getters-actions.md` for: Complete Options API integration, all mappers (mapStores, mapState, mapWritableState, mapActions)
---
Migrating from Vuex
Load `references/vuex-migration.md` for complete migration guide.
Quick Conversion Overview
Key Changes: 1. Remove namespaced (automatic via store ID) 2. Eliminate mutations (direct state mutation) 3. Replace commit() with direct mutations 4. Replace rootState/rootGetters with store imports 5. Use store.$reset() instead of custom clear mutations
Directory: store/modules/ → stores/ (each module = separate store)
→ Load `references/vuex-migration.md` for: Complete conversion steps, component migration, checklist, gradual migration strategy
---
Critical Rules
Always Do
✅ Define all state properties in state() or return them from setup stores ✅ Use storeToRefs() when destructuring state/getters in components ✅ Call app.use(pinia) BEFORE mounting the app ✅ Return all state from setup stores (private state breaks SSR/DevTools) ✅ Call useStore() inside functions/callbacks when used outside components ✅ Use acceptHMRUpdate() for development HMR support ✅ Type return values when getters use this to access other getters ✅ Use devalue for SSR state serialization (prevents XSS) ✅ Hydrate state BEFORE calling any useStore() on the client (SSR) ✅ Call all useStore() BEFORE any await in async actions (SSR)
Never Do
❌ Add state properties dynamically after store creation ❌ Destructure store directly without storeToRefs() (loses reactivity) ❌ Use arrow functions for actions (need this context) ❌ Return private state in setup stores (breaks SSR/DevTools/plugins) ❌ Call useStore() at module top-level (before Pinia installed) ❌ Create circular dependencies between stores (both reading each other's state) ❌ Use JSON.stringify() for SSR serialization (vulnerable to XSS) ❌ Call useStore() after await in actions (breaks SSR) ❌ Forget to type getter return values when using this ❌ Skip beforeEach(() => setActivePinia(createPinia())) in unit tests
---
Known Issues Prevention
This skill prevents 12 documented issues:
Issue #1: Lost Reactivity from Direct Destructuring
Error: State changes don't update in template after destructuring Why It Happens: JavaScript destructuring breaks Vue reactivity Prevention: Always use storeToRefs() for state/getters
Issue #2: Cannot Add State Properties Dynamically
Error: New properties added after store creation aren't reactive Why It Happens: Pinia needs all properties defined upfront for reactivity Prevention: Declare all properties in state(), even if initially undefined
Issue #3: Store Not Found Before Pinia Install
Error: getActivePinia() returns undefined Why It Happens: Calling useStore() before app.use(pinia) Prevention: Call app.use(pinia) before mounting or accessing stores
Issue #4: Setup Store Private State Breaks SSR
Error: State not serialized/hydrated correctly in SSR Why It Happens: Properties not returned from setup aren't tracked Prevention: Return ALL state properties from setup stores
Issue #5: Getters with this Don't Infer Types
Error: TypeScript can't infer return type when getter uses this Source: Known TypeScript limitation with Pinia Prevention: Explicitly type return value: getterName(): ReturnType { ... }
Issue #6: Options API Store Suffix Confusion
Error: Can't find this.counterStore in component Why It Happens: mapStores() automatically adds 'Store' suffix Prevention: Use store name + 'Store' or call setMapStoreSuffix()
Issue #7: Actions Called After await Break SSR
Error: Wrong Pinia instance used in SSR, causing state pollution Why It Happens: await changes execution context in async functions Prevention: Call all useStore() before any await statements
Issue #8: Circular Store Dependencies Crash App
Error: Maximum call stack exceeded Why It Happens: Both stores read each other's state during initialization Prevention: Use getters/actions for cross-store access, not setup-time reads
Issue #9: XSS Vulnerability in SSR State Serialization
Error: User input in state can execute malicious scripts Why It Happens: JSON.stringify() doesn't escape executable code Prevention: Use devalue library for safe serialization
Issue #10: HMR Doesn't Work in Development
Error: Changes to store require full page reload Why It Happens: Vite/webpack HMR not configured for store Prevention: Add acceptHMRUpdate() block to each store file
Issue #11: Composables Return Functions Break Option Stores
Error: Store state contains non-serializable functions Why It Happens: Option stores state() can only return writable refs Prevention: Use setup stores for complex composables, or extract only writable state
Issue #12: State Not Reset Between Unit Tests
Error: Tests affect each other, sporadic failures Why It Happens: Single Pinia instance shared across tests Prevention: beforeEach(() => setActivePinia(createPinia())) in test suites
---
Package Versions (Verified 2025-11-21)
Core: pinia@^3.0.4, vue@^3.5.24 Nuxt: @pinia/nuxt@^0.11.2, nuxt@^3.13.0 Testing: @pinia/testing@^1.0.2, vitest@^1.0.0 SSR: devalue@^5.3.2 (for safe serialization)
---
Common Patterns
See reference files for complete pattern examples:
- Authentication stores →
references/state-getters-actions.md - Persistence plugins →
references/plugins-composables.md - Form stores →
references/store-syntax-guide.md(setup store examples) - Router integration →
references/state-getters-actions.md(accessing stores outside components)
---
Official Documentation
- Pinia: https://pinia.vuejs.org/
- Getting Started: https://pinia.vuejs.org/getting-started.html
- Core Concepts: https://pinia.vuejs.org/core-concepts/
- SSR Guide: https://pinia.vuejs.org/ssr/
- Nuxt Integration: https://pinia.vuejs.org/ssr/nuxt.html
- Testing: https://pinia.vuejs.org/cookbook/testing.html
- Vuex Migration: https://pinia.vuejs.org/cookbook/migration-vuex.html
- GitHub: https://github.com/vuejs/pinia
---
Troubleshooting
Problem: "getActivePinia() was called with no active Pinia"
Solution: 1. Ensure app.use(pinia) is called before mounting 2. If outside component, call useStore() inside callback/function 3. For SSR, pass pinia instance explicitly: useStore(pinia)
Problem: State changes don't update in template
Solution: Use storeToRefs() instead of direct destructuring
Problem: Getter using this has TypeScript errors
Solution: Explicitly type the return value: myGetter(): ReturnType { return this.otherGetter }
Problem: $reset() not available in setup store
Solution: Implement custom reset manually:
function $reset() {
count.value = 0
name.value = ''
}
return { count, name, $reset }Problem: HMR not working for stores
Solution: Add HMR acceptance block:
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useMyStore, import.meta.hot))
}Problem: Tests fail intermittently
Solution: Create fresh Pinia in beforeEach():
beforeEach(() => {
setActivePinia(createPinia())
})---
When to Load References
Load `references/store-syntax-guide.md` when:
- Need detailed comparison between Option and Setup store syntaxes
- Deciding which syntax to use for a new store
- Questions about Option vs Setup stores trade-offs
- Need complete examples of both syntaxes
Load `references/state-getters-actions.md` when:
- Need complete API reference for state, getters, or actions
- Questions about
$patch,$subscribe, or$onAction - Implementing store composition patterns
- Using Options API mappers (
mapStores,mapState,mapActions) - Accessing stores outside components (router, plugins)
Load `references/plugins-composables.md` when:
- Creating custom Pinia plugins
- Integrating VueUse or other composables into stores
- Need persistence, routing, or logging plugin patterns
- Questions about TypeScript typing for plugins
- Advanced composables integration
Load `references/ssr-and-nuxt.md` when:
- Setting up server-side rendering
- Integrating with Nuxt 3/4
- Questions about state hydration or serialization
- SSR-related errors (wrong Pinia instance, hydration mismatch)
- Nuxt auto-imports or configuration
- Server-side data fetching patterns
Load `references/testing-guide.md` when:
- Setting up unit tests for stores
- Testing components that use Pinia stores
- Need to stub actions or mock getters
- Questions about
createTestingPinia - Testing SSR stores
- Vitest or testing framework integration
Load `references/vuex-migration.md` when:
- Migrating existing Vuex codebase to Pinia
- Questions about Vuex→Pinia conversion
- Need migration checklist or examples
- Gradual migration strategy needed
---
Complete Setup Checklist
- [ ] Installed
piniapackage - [ ] Created Pinia instance with
createPinia() - [ ] Registered with
app.use(pinia)before mounting - [ ] Created stores directory (e.g.,
src/stores/) - [ ] Defined at least one store with
defineStore() - [ ] Used
storeToRefs()when destructuring in components - [ ] Typed getter return values when using
this - [ ] Added HMR support with
acceptHMRUpdate()(development) - [ ] Configured SSR hydration (if using SSR)
- [ ] Configured
@pinia/nuxt(if using Nuxt) - [ ] Set up testing with
createTestingPinia()(if testing) - [ ] All stores follow consistent naming:
use[Name]Store - [ ] Verified DevTools integration works
---
Questions? Issues?
1. Check official docs: https://pinia.vuejs.org/ 2. Review "Known Issues Prevention" section above 3. Verify setup checklist is complete 4. Check for TypeScript configuration issues 5. Ensure Pinia is installed before using stores
[TODO: Example Template File]
[TODO: This directory contains files that will be used in the OUTPUT that Claude produces.]
[TODO: Examples:]
- Templates (.html, .tsx, .md)
- Images (.png, .svg)
- Fonts (.ttf, .woff)continue
- Boilerplate code
- Configuration file templates
[TODO: Delete this file and add your actual assets]
These files are NOT loaded into context. They are copied or used directly in the final output.
[TODO: Reference Document Name]
[TODO: This file contains reference documentation that Claude can load when needed.]
[TODO: Delete this file if you don't have reference documentation to provide.]
Purpose
[TODO: Explain what information this document contains]
When Claude Should Use This
[TODO: Describe specific scenarios where Claude should load this reference]
Content
[TODO: Add your reference content here - schemas, guides, specifications, etc.]
---
Note: This file is NOT loaded into context by default. Claude will only load it when:
- It determines the information is needed
- You explicitly ask Claude to reference it
- The SKILL.md instructions direct Claude to read it
Keep this file under 10k words for best performance.
Pinia Plugins and Composables Guide
Complete guide for extending Pinia with plugins and integrating Vue composables into stores.
Last Updated: 2025-11-21
---
Plugins
Creating a Plugin
export function myPlugin(context) {
context.pinia // Pinia instance
context.app // Vue app instance (createApp)
context.store // Store being augmented
context.options // Store definition options
// Return object to add properties to every store
return {
secret: 'the cake is a lie'
}
}
// Register
const pinia = createPinia()
pinia.use(myPlugin)Adding State via Plugins
import { ref } from 'vue'
pinia.use(({ store }) => {
const secret = ref('my-secret')
// Add to both store and $state for SSR
store.secret = secret
store.$state.secret = secret
return { secret }
})Adding Options via Plugins
// Define custom store option
defineStore('search', {
// Custom option
debounce: {
search: 300,
reset: 100
},
actions: {
search() { /* ... */ },
reset() { /* ... */ }
}
})
// Plugin reads and implements
import { debounce } from 'lodash'
pinia.use(({ options, store }) => {
if (options.debounce) {
return Object.keys(options.debounce).reduce((debouncedActions, action) => {
debouncedActions[action] = debounce(
store[action],
options.debounce[action]
)
return debouncedActions
}, {})
}
})TypeScript Plugin Typing
import 'pinia'
declare module 'pinia' {
export interface PiniaCustomProperties {
// Add custom store properties
router: Router
}
export interface PiniaCustomStateProperties<S> {
// Add custom state properties
myCustomState: string
}
export interface DefineStoreOptionsBase<S, Store> {
// Add custom store options
debounce?: Partial<Record<keyof StoreActions<Store>, number>>
}
}---
Using Composables in Stores
Option Stores
Call composables in state():
import { useLocalStorage } from '@vueuse/core'
export const useStore = defineStore('store', {
state: () => ({
// ✅ Works - returns writable ref
theme: useLocalStorage('theme', 'dark')
})
})Limitations:
- Can only return writable state (
ref()) - Cannot use composables that return functions or readonly data
Setup Stores
Almost any composable works:
import { useMediaControls } from '@vueuse/core'
export const useVideoStore = defineStore('video', () => {
const videoEl = ref<HTMLVideoElement>()
// ✅ All properties auto-categorized
const { playing, volume, currentTime, togglePictureInPicture } =
useMediaControls(videoEl, { src: '/video.mp4' })
return {
playing,
volume,
currentTime,
togglePictureInPicture,
videoEl
}
})SSR Handling:
import { skipHydrate } from 'pinia'
return {
// Don't hydrate this from SSR state
videoEl: skipHydrate(videoEl)
}---
Common Plugin Patterns
Pattern 1: LocalStorage Persistence Plugin
import { PiniaPluginContext } from 'pinia'
export function persistPlugin({ store }: PiniaPluginContext) {
// Restore state from localStorage
const stored = localStorage.getItem(store.$id)
if (stored) {
store.$patch(JSON.parse(stored))
}
// Save state to localStorage on every change
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// Register
const pinia = createPinia()
pinia.use(persistPlugin)When to use: Persisting user preferences, cart data, etc.
Pattern 2: Router Integration Plugin
import { Router } from 'vue-router'
export function routerPlugin(router: Router) {
return ({ store }: PiniaPluginContext) => {
store.router = router
}
}
// Register
const pinia = createPinia()
pinia.use(routerPlugin(router))
// Now all stores have access to router
export const useStore = defineStore('store', {
actions: {
navigateHome() {
this.router.push('/')
}
}
})Pattern 3: Debounce Plugin
import { debounce } from 'lodash'
pinia.use(({ options, store }) => {
if (options.debounce) {
return Object.keys(options.debounce).reduce((debouncedActions, action) => {
debouncedActions[action] = debounce(
store[action],
options.debounce[action]
)
return debouncedActions
}, {})
}
})
// Usage
export const useSearchStore = defineStore('search', {
debounce: {
search: 300 // 300ms debounce
},
actions: {
search(query: string) {
// This will be debounced
}
}
})Pattern 4: Logger Plugin
pinia.use(({ store }) => {
store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`[Action] ${store.$id}.${name}`, args)
after((result) => {
console.log(`[Success] ${store.$id}.${name} (${Date.now() - startTime}ms)`, result)
})
onError((error) => {
console.error(`[Error] ${store.$id}.${name}`, error)
})
})
})Pattern 5: API Client Plugin
export function apiPlugin(apiClient: ApiClient) {
return ({ store }: PiniaPluginContext) => {
store.$api = apiClient
}
}
// Register
const pinia = createPinia()
pinia.use(apiPlugin(myApiClient))
// Usage
export const useUserStore = defineStore('user', {
actions: {
async fetchUsers() {
this.users = await this.$api.get('/users')
}
}
})---
VueUse Composables Integration
useLocalStorage Example
import { useLocalStorage } from '@vueuse/core'
export const useSettingsStore = defineStore('settings', () => {
// Automatically synced with localStorage
const theme = useLocalStorage('theme', 'dark')
const language = useLocalStorage('language', 'en')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
}
return { theme, language, toggleTheme }
})useMediaQuery Example
import { useMediaQuery } from '@vueuse/core'
export const useLayoutStore = defineStore('layout', () => {
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')
return { isMobile, isTablet, isDesktop }
})useFetch Example
import { useFetch } from '@vueuse/core'
export const useDataStore = defineStore('data', () => {
const { data, error, isFetching, execute } = useFetch('/api/data').json()
return { data, error, isFetching, execute }
})useWebSocket Example
import { useWebSocket } from '@vueuse/core'
export const useChatStore = defineStore('chat', () => {
const { status, data, send, open, close } = useWebSocket('ws://localhost:3000')
const messages = ref<string[]>([])
watch(data, (newMessage) => {
if (newMessage) {
messages.value.push(newMessage)
}
})
function sendMessage(text: string) {
send(text)
}
return { status, messages, sendMessage, open, close }
})---
Advanced Plugin Patterns
Conditional Plugin Application
pinia.use(({ store, options }) => {
// Only apply to stores with specific option
if (options.persist) {
// Implement persistence
}
})
// Usage
export const useStore = defineStore('store', {
persist: true, // Enable persistence for this store
state: () => ({ count: 0 })
})Plugin with Cleanup
pinia.use(({ store }) => {
const interval = setInterval(() => {
store.$state.timestamp = Date.now()
}, 1000)
// Cleanup when store is disposed
store.$dispose(() => {
clearInterval(interval)
})
})Cross-Store Plugin
pinia.use(({ store }) => {
if (store.$id === 'auth') {
// Watch auth state changes
watch(() => store.isAuthenticated, (isAuth) => {
if (!isAuth) {
// Clear all other stores when user logs out
const allStores = pinia._s // Access all stores
allStores.forEach((s) => {
if (s.$id !== 'auth' && s.$reset) {
s.$reset()
}
})
}
})
}
})---
Composables Best Practices
DO:
- ✅ Use setup stores for composables integration
- ✅ Return all composable outputs for SSR
- ✅ Use
skipHydrate()for browser-only refs (DOM elements) - ✅ Handle composable errors gracefully
- ✅ Document composable dependencies
DON'T:
- ❌ Use composables returning functions in option stores'
state() - ❌ Forget to return composable values from setup stores
- ❌ Mix composables with complex computed logic (keep simple)
- ❌ Ignore SSR compatibility of composables
- ❌ Use DOM-dependent composables without SSR guards
---
Plugin TypeScript Examples
Strong Typing for Custom Properties
// types.ts
import 'pinia'
import { Router } from 'vue-router'
import { ApiClient } from './api'
declare module 'pinia' {
export interface PiniaCustomProperties {
router: Router
$api: ApiClient
}
}
// Now all stores have typed access
export const useStore = defineStore('store', {
actions: {
navigate() {
this.router.push('/') // ✅ Fully typed
},
async fetchData() {
const data = await this.$api.get('/data') // ✅ Fully typed
}
}
})Custom State Properties
declare module 'pinia' {
export interface PiniaCustomStateProperties<S> {
createdAt: number
updatedAt: number
}
}
// Plugin implementation
pinia.use(({ store }) => {
store.$state.createdAt = Date.now()
store.$state.updatedAt = Date.now()
store.$subscribe(() => {
store.$state.updatedAt = Date.now()
})
})---
See also:
ssr-and-nuxt.mdfor SSR-safe composables usagetesting-guide.mdfor testing plugins
Pinia SSR and Nuxt Integration Guide
Complete guide for server-side rendering with Pinia and Nuxt integration.
Last Updated: 2025-11-21
---
Server-Side Rendering (SSR)
Basic SSR Setup
Works automatically when using stores in setup(), getters, or actions.
State Hydration
Server Side:
import { renderToString } from '@vue/server-renderer'
import devalue from 'devalue'
const pinia = createPinia()
const app = createSSRApp(App)
app.use(pinia)
const html = await renderToString(app)
// Serialize state (devalue prevents XSS)
const state = devalue(pinia.state.value)
const fullHtml = `
<html>
<body>
<div id="app">${html}</div>
<script>window.__pinia = ${state}</script>
</body>
</html>
`Client Side:
const pinia = createPinia()
// CRITICAL: Hydrate BEFORE using any stores
if (typeof window !== 'undefined') {
pinia.state.value = window.__pinia
}
const app = createApp(App)
app.use(pinia)CRITICAL:
- Always escape serialized state to prevent XSS
- Use
devaluelibrary (not JSON.stringify) for complex data - Hydrate before calling any
useStore()
---
Nuxt Integration
Installation
Install the official module:
bunx nuxi@latest module add piniaThis adds @pinia/nuxt to your project.
Configuration
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
storesDirs: ['./stores/**', './custom-folder/stores/**']
}
})Auto-imports
The Nuxt module auto-imports:
defineStore()storeToRefs()usePinia()acceptHMRUpdate()- All stores in
stores/directory
Example:
// stores/user.ts
export const useUserStore = defineStore('user', {
state: () => ({ name: 'Eduardo' })
})
// pages/index.vue - NO IMPORTS NEEDED
<script setup>
const userStore = useUserStore() // Auto-imported
const { name } = storeToRefs(userStore) // Auto-imported
</script>---
Nuxt Server-Side Data Fetching
Using callOnce for Data Fetching
<script setup>
const store = useStore()
// Runs once on server, cached
await callOnce('user', () => store.fetchUser())
// Refetch on every navigation
await callOnce('user', () => store.fetchUser(), { mode: 'navigation' })
</script>Options:
mode: 'server'(default): Runs once on server, cachedmode: 'navigation': Refetch on every navigationmode: 'always': Refetch on every render
Using useFetch in Actions
export const useUserStore = defineStore('user', {
state: () => ({
users: [] as User[]
}),
actions: {
async fetchUsers() {
// In Nuxt, use $fetch for SSR-compatible requests
this.users = await $fetch('/api/users')
}
}
})Server-Only Actions
export const useUserStore = defineStore('user', {
actions: {
async fetchServerData() {
// Only run on server
if (process.server) {
this.data = await fetch('/api/server-data')
}
}
}
})---
SSR Best Practices
DO:
- ✅ Use
devaluefor state serialization (prevents XSS) - ✅ Hydrate state before calling
useStore() - ✅ Call all
useStore()beforeawaitin async actions - ✅ Return all state from setup stores for SSR tracking
- ✅ Use
skipHydrate()for browser-only refs (DOM elements)
DON'T:
- ❌ Use
JSON.stringify()for serialization (XSS vulnerability) - ❌ Call
useStore()afterawait(wrong Pinia instance) - ❌ Forget to pass Pinia instance explicitly in SSR setup code
- ❌ Use browser-only APIs without SSR guards
- ❌ Keep private state in setup stores (breaks SSR)
---
SSR Pitfalls and Solutions
Pitfall 1: Wrong Pinia Instance After Await
Problem:
actions: {
async loadData() {
const data = await fetch('/api')
// ❌ May use wrong Pinia instance in SSR
const authStore = useAuthStore()
}
}Solution:
actions: {
async loadData() {
// ✅ Call useStore() BEFORE await
const authStore = useAuthStore()
const data = await fetch('/api')
// Safe to use authStore now
if (authStore.isAuthenticated) {
this.data = data
}
}
}Pitfall 2: Browser APIs in SSR
Problem:
export const useStore = defineStore('store', () => {
const theme = ref(localStorage.getItem('theme')) // ❌ Crashes on server
return { theme }
})Solution:
export const useStore = defineStore('store', () => {
const theme = ref('')
// Only run on client
if (process.client) {
theme.value = localStorage.getItem('theme') || 'dark'
}
return { theme }
})Pitfall 3: Private State in Setup Stores
Problem:
export const useStore = defineStore('store', () => {
const publicState = ref(0)
const privateState = ref(0) // ❌ Not returned
return { publicState } // privateState not tracked for SSR
})Solution:
export const useStore = defineStore('store', () => {
const publicState = ref(0)
const privateState = ref(0)
// ✅ Return ALL state
return { publicState, privateState }
})---
Nuxt 3/4 Specific Patterns
Using Stores in Middleware
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated && to.path !== '/login') {
return navigateTo('/login')
}
})Using Stores in Server Routes
// server/api/user.ts
export default defineEventHandler(async (event) => {
const pinia = usePinia()
const userStore = useUserStore(pinia)
return {
user: userStore.currentUser
}
})Using Stores in Plugins
// plugins/init.ts
export default defineNuxtPlugin(({ $pinia }) => {
const authStore = useAuthStore($pinia)
// Initialize auth on app start
authStore.init()
})---
Advanced SSR Patterns
Per-Request State Isolation
Nuxt automatically creates separate Pinia instances per request. No manual setup needed.
State Persistence Across Navigation
export const useStore = defineStore('store', {
state: () => ({
data: null
}),
actions: {
async loadData() {
// Cache data across client-side navigation
if (this.data) return
this.data = await $fetch('/api/data')
}
}
})Prefetching Store Data
<script setup>
const store = useStore()
// Prefetch data during SSR
await store.loadData()
</script>
<template>
<div>{{ store.data }}</div>
</template>---
Debugging SSR Issues
Check Pinia Instance
export const useStore = defineStore('store', () => {
const pinia = usePinia()
console.log('Pinia instance:', pinia)
console.log('Is server:', process.server)
return {}
})Verify State Hydration
// Client-side
onMounted(() => {
const pinia = usePinia()
console.log('Hydrated state:', pinia.state.value)
})Test with/without JavaScript
Disable JavaScript in browser to verify SSR is working correctly. Page should still render with initial data.
---
Nuxt Module Configuration
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
// Custom stores directories
storesDirs: ['./stores/**', './modules/**/store/**'],
// Disable auto-imports (not recommended)
autoImports: false,
// Custom auto-import names
imports: [
'defineStore',
'storeToRefs',
'acceptHMRUpdate',
// Don't auto-import specific stores
['useUserStore', { as: 'useUser' }]
]
}
})---
Testing SSR
Server-Side Test
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { renderToString } from '@vue/server-renderer'
describe('SSR', () => {
it('renders store state', async () => {
const pinia = createPinia()
const app = createSSRApp({
setup() {
const store = useStore()
store.count = 5
return () => h('div', store.count)
}
})
app.use(pinia)
const html = await renderToString(app)
expect(html).toContain('5')
})
})---
See also:
testing-guide.mdfor SSR testing patternsplugins-composables.mdfor SSR-safe composables
Pinia State, Getters, and Actions Guide
Complete API reference for managing state, computed properties, and business logic in Pinia stores.
Last Updated: 2025-11-21
---
State Management
Defining State
Option Stores:
state: () => ({
count: 0,
name: 'Eduardo',
isAdmin: true,
items: [],
// Even undefined values must be declared
user: undefined as User | undefined
})Setup Stores:
const count = ref(0)
const name = ref('Eduardo')
const isAdmin = ref(true)
const items = ref<Item[]>([])
const user = ref<User>()
return { count, name, isAdmin, items, user }CRITICAL:
- ALL state properties MUST be defined in
state()or returned from setup - Cannot add new state properties dynamically after store creation
- Use proper types for TypeScript inference
Accessing State
Direct access - read and write:
const store = useCounterStore()
// Read
console.log(store.count)
// Write
store.count++
store.name = 'Alice'
// Works with v-model
<input v-model="store.name" />Mutating State
Method 1: Direct Mutation
store.count++
store.name = 'Alice'Method 2: $patch with Object
store.$patch({
count: store.count + 1,
name: 'Alice'
})Method 3: $patch with Function
// Best for complex mutations (arrays, etc.)
store.$patch((state) => {
state.items.push({ name: 'shoes', quantity: 1 })
state.hasChanged = true
})Resetting State
Option Stores:
store.$reset() // Restores to initial stateSetup Stores:
// Must implement manually
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function $reset() {
count.value = 0
}
return { count, $reset }
})Subscribing to State Changes
store.$subscribe((mutation, state) => {
// Called after each patch
console.log(mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log(mutation.storeId) // 'counter'
console.log(mutation.payload) // patch object
// Persist to localStorage
localStorage.setItem('counter', JSON.stringify(state))
})
// Options
store.$subscribe(callback, {
detached: true // Keep subscription after component unmounts
})
store.$subscribe(callback, {
flush: 'sync' // Call immediately (instead of post-component-update)
})---
Getters (Computed State)
Defining Getters
Arrow Functions (Recommended):
getters: {
doubleCount: (state) => state.count * 2,
upperName: (state) => state.name.toUpperCase()
}Regular Functions:
getters: {
doubleCount(state) {
return state.count * 2
}
}Accessing Other Getters
IMPORTANT: When using this, must use regular function and type return value:
getters: {
doubleCount: (state) => state.count * 2,
// Must type return when using 'this'
doublePlusOne(): number {
return this.doubleCount + 1
}
}Passing Arguments to Getters
Return a function from the getter:
getters: {
getUserById: (state) => {
return (userId: number) => state.users.find(user => user.id === userId)
}
}
// Usage in component
<script setup>
import { storeToRefs } from 'pinia'
const store = useUserStore()
const { getUserById } = storeToRefs(store)
</script>
<template>
<p>User 2: {{ getUserById(2) }}</p>
</template>NOTE: Returning functions prevents caching. Consider internal caching if performance matters.
Accessing Other Stores' Getters
import { useOtherStore } from './other-store'
getters: {
combinedData(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
}
}---
Actions (Business Logic)
Defining Actions
actions: {
increment() {
this.count++
},
incrementBy(amount: number) {
this.count += amount
},
reset() {
this.count = 0
}
}NOTE: Cannot use arrow functions (need this context)
Async Actions
Actions can be async and await any promises:
actions: {
async registerUser(login: string, password: string) {
try {
this.userData = await api.post({ login, password })
showToast('Registration successful')
} catch (error) {
showToast(error)
return error
}
},
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
}
}
}Accessing Other Store Actions
actions: {
async loginAndLoad() {
const auth = useAuthStore()
await auth.login()
// After auth succeeds, load user data
await this.loadUserData()
}
}Subscribing to Actions
const unsubscribe = store.$onAction(
({
name, // action name
store, // store instance
args, // array of action parameters
after, // hook after action returns/resolves
onError // hook if action throws/rejects
}) => {
const startTime = Date.now()
console.log(`Action "${name}" started with params:`, args)
after((result) => {
console.log(`Finished in ${Date.now() - startTime}ms`)
console.log('Result:', result)
})
onError((error) => {
console.error(`Failed: ${error}`)
})
}
)
// With second parameter true, subscription persists after component unmount
store.$onAction(callback, true)---
Store Destructuring with Reactivity
Problem: Direct Destructuring Breaks Reactivity
// ❌ DON'T DO THIS
const { name, count } = store // Loses reactivity!Solution: Use storeToRefs()
import { storeToRefs } from 'pinia'
// ✅ Extract state/getters with reactivity
const { name, count, doubleCount } = storeToRefs(store)
// ✅ Actions can be destructured directly (no need for storeToRefs)
const { increment, reset } = store
// Now reactive in templates
<template>
<p>{{ name }}</p> <!-- Reactive -->
<button @click="increment">+</button>
</template>---
Store Composition
Three Safe Patterns
1. Nested Stores (Setup Pattern):
export const useCartStore = defineStore('cart', () => {
const user = useUserStore() // ✅ Top-level call
const items = ref([])
function addItem(item) {
items.value.push(item)
}
return { items, addItem, user }
})2. Shared Getters:
getters: {
userData() {
const user = useUserStore() // ✅ Inside getter
return user.data
}
}3. Shared Actions:
actions: {
async loadData() {
const user = useUserStore() // ✅ Inside action
if (user.isAuthenticated) {
this.data = await fetch('/data')
}
}
}❌ Circular Dependencies to Avoid
// ❌ NEVER: Both stores read each other's state at setup
export const useStoreA = defineStore('a', () => {
const storeB = useStoreB()
const value = storeB.value // ❌ Circular dependency
return { value }
})
export const useStoreB = defineStore('b', () => {
const storeA = useStoreA()
const value = storeA.value // ❌ Circular dependency
return { value }
})SSR Consideration for Async Actions
actions: {
async loadData() {
// ✅ All useStore() calls BEFORE await
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
// ❌ Don't call useStore() after await (breaks SSR)
const data = await fetch('/api')
// Store calls already made, safe to use
if (authStore.isAuthenticated) {
this.data = data
}
}
}---
Using Stores Outside Components
The Problem
Stores need the Pinia instance, which is auto-injected in components but not available in module scope.
❌ Wrong: Accessing Store at Module Level
// router.ts
import { useUserStore } from '@/stores/user'
// ❌ Fails: Pinia not installed yet
const userStore = useUserStore()
router.beforeEach((to) => {
if (userStore.isLoggedIn) { /* ... */ }
})✅ Right: Accessing Store Inside Callbacks
// router.ts
import { useUserStore } from '@/stores/user'
router.beforeEach((to) => {
// ✅ Works: Called after Pinia is installed
const userStore = useUserStore()
if (userStore.isLoggedIn) { /* ... */ }
})Why it works: Router guards execute AFTER app.use(pinia) completes.
SSR: Explicit Pinia Instance
// server-side
export function setupRouter(pinia) {
router.beforeEach((to) => {
const userStore = useUserStore(pinia) // Pass explicitly
})
}---
Options API Usage
With setup()
<script>
import { useCounterStore } from '@/stores/counter'
export default {
setup() {
const counterStore = useCounterStore()
return { counterStore }
},
methods: {
handleClick() {
this.counterStore.increment()
}
}
}
</script>Without setup() - Using Mappers
mapStores:
<script>
import { mapStores } from 'pinia'
import { useCounterStore, useUserStore } from '@/stores'
export default {
computed: {
...mapStores(useCounterStore, useUserStore)
// Adds this.counterStore and this.userStore
},
methods: {
handleClick() {
this.counterStore.increment()
}
}
}
</script>mapState (read-only):
<script>
import { mapState } from 'pinia'
import { useCounterStore } from '@/stores/counter'
export default {
computed: {
...mapState(useCounterStore, ['count', 'doubleCount']),
// Adds this.count and this.doubleCount (read-only)
...mapState(useCounterStore, {
myCount: 'count',
myDouble: store => store.doubleCount
})
}
}
</script>mapWritableState (read-write):
<script>
import { mapWritableState } from 'pinia'
import { useCounterStore } from '@/stores/counter'
export default {
computed: {
...mapWritableState(useCounterStore, ['count', 'name'])
// Can modify: this.count++
}
}
</script>mapActions:
<script>
import { mapActions } from 'pinia'
import { useCounterStore } from '@/stores/counter'
export default {
methods: {
...mapActions(useCounterStore, ['increment', 'reset'])
// Adds this.increment() and this.reset()
}
}
</script>Customizing Store Suffix:
import { setMapStoreSuffix } from 'pinia'
setMapStoreSuffix('') // Remove 'Store' suffix
// ...mapStores(useCounter) -> this.counter (not this.counterStore)
setMapStoreSuffix('_store')
// ...mapStores(useCounter) -> this.counter_store---
See also:
store-syntax-guide.mdfor choosing between Option and Setup storesplugins-composables.mdfor advanced store enhancement
Pinia Store Syntax Guide
Complete comparison of the two store definition syntaxes in Pinia v3.
Last Updated: 2025-11-21
---
The Two Store Syntaxes
Option Stores (Recommended for Beginners)
Similar to Vue's Options API structure:
export const useCounterStore = defineStore('counter', {
// State = data()
state: () => ({
count: 0,
name: 'Eduardo',
items: [] as Item[]
}),
// Getters = computed properties
getters: {
doubleCount: (state) => state.count * 2,
// Access other getters with regular functions
doublePlusOne(): number {
return this.doubleCount + 1
}
},
// Actions = methods
actions: {
increment() {
this.count++
},
async fetchData() {
// Actions can be async
const data = await api.fetch()
this.items = data
}
}
})When to use:
- Simpler mental model (matches Options API)
- Built-in
$reset()method - Better for teams familiar with Vuex
Setup Stores (Recommended for Advanced Users)
Uses Composition API pattern for greater flexibility:
export const useCounterStore = defineStore('counter', () => {
// ref() = state
const count = ref(0)
const name = ref('Eduardo')
const items = ref([])
// computed() = getters
const doubleCount = computed(() => count.value * 2)
// function() = actions
function increment() {
count.value++
}
async function fetchData() {
const data = await api.fetch()
items.value = data
}
// MUST return everything you want exposed
return { count, name, items, doubleCount, increment, fetchData }
})CRITICAL for Setup Stores:
- Must return ALL state properties for Pinia to track them
- Private properties (not returned) break SSR, DevTools, and plugins
- Can use watchers and composables directly
- No built-in
$reset()- must implement manually
When to use:
- Need composables integration
- Want reactive watches in stores
- Prefer Composition API mental model
- Need complex state logic
---
Syntax Comparison Table
| Feature | Option Stores | Setup Stores |
|---|---|---|
| Syntax | Object options | Function with return |
| State | state: () => ({...}) | const x = ref() |
| Getters | getters: {...} | const x = computed() |
| Actions | actions: {...} | function x() {...} |
| $reset() | ✅ Built-in | ❌ Must implement |
| Composables | Limited support | ✅ Full support |
| Watchers | ❌ Not available | ✅ Available |
| Learning curve | Easier | Steeper |
| Flexibility | Lower | Higher |
| TypeScript | Good | Excellent |
| SSR safety | Automatic | Manual (must return all) |
| DevTools | Automatic | Manual (must return all) |
---
Option Store Complete Example
import { defineStore } from 'pinia'
interface User {
id: number
name: string
email: string
}
export const useUserStore = defineStore('user', {
state: () => ({
users: [] as User[],
currentUserId: null as number | null,
loading: false,
error: null as string | null
}),
getters: {
currentUser: (state) => {
return state.users.find(u => u.id === state.currentUserId)
},
// Access other getters - must use regular function
currentUserEmail(): string | undefined {
return this.currentUser?.email
},
// Getter with arguments
getUserById: (state) => {
return (userId: number) => state.users.find(u => u.id === userId)
}
},
actions: {
async fetchUsers() {
this.loading = true
this.error = null
try {
const response = await fetch('/api/users')
this.users = await response.json()
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
setCurrentUser(userId: number) {
this.currentUserId = userId
},
clearCurrentUser() {
this.currentUserId = null
}
}
})---
Setup Store Complete Example
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
}
export const useUserStore = defineStore('user', () => {
// State
const users = ref<User[]>([])
const currentUserId = ref<number | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Getters
const currentUser = computed(() => {
return users.value.find(u => u.id === currentUserId.value)
})
const currentUserEmail = computed(() => {
return currentUser.value?.email
})
// Getter with arguments (returns function)
const getUserById = computed(() => {
return (userId: number) => users.value.find(u => u.id === userId)
})
// Actions
async function fetchUsers() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/users')
users.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function setCurrentUser(userId: number) {
currentUserId.value = userId
}
function clearCurrentUser() {
currentUserId.value = null
}
// Manual $reset implementation
function $reset() {
users.value = []
currentUserId.value = null
loading.value = false
error.value = null
}
// MUST return everything
return {
// State
users,
currentUserId,
loading,
error,
// Getters
currentUser,
currentUserEmail,
getUserById,
// Actions
fetchUsers,
setCurrentUser,
clearCurrentUser,
$reset
}
})---
Choosing Between Syntaxes
Use Option Stores if:
- Team is familiar with Vue Options API or Vuex
- Need built-in
$reset()functionality - Want simpler mental model
- Don't need advanced composables integration
Use Setup Stores if:
- Team prefers Composition API
- Need to integrate VueUse or other composables
- Want to use watchers inside stores
- Need maximum flexibility
- Building complex state logic
Recommendation: Start with Option Stores for simpler use cases. Migrate to Setup Stores when you need composables or advanced patterns.
---
See also:
state-getters-actions.mdfor detailed API referenceplugins-composables.mdfor composables integration patterns
Pinia Testing Guide
Complete guide for testing Pinia stores and components that use stores.
Last Updated: 2025-11-21
---
Unit Testing Stores
Basic Store Testing
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
import { describe, it, expect, beforeEach } from 'vitest'
describe('Counter Store', () => {
beforeEach(() => {
// Fresh Pinia for each test
setActivePinia(createPinia())
})
it('increments', () => {
const counter = useCounterStore()
expect(counter.count).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
})
it('doubles count', () => {
const counter = useCounterStore()
counter.count = 5
expect(counter.doubleCount).toBe(10)
})
})CRITICAL: Always use beforeEach(() => setActivePinia(createPinia())) to create fresh Pinia instances between tests.
---
Testing with Plugins
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { somePlugin } from './plugins'
describe('Store with Plugin', () => {
let app
beforeEach(() => {
app = createApp({})
const pinia = createPinia().use(somePlugin)
app.use(pinia)
setActivePinia(pinia)
})
})---
Component Testing
Installation
bun add -d @pinia/testingBasic Component Test
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import Counter from './Counter.vue'
describe('Counter Component', () => {
it('displays count', () => {
const wrapper = mount(Counter, {
global: {
plugins: [createTestingPinia()]
}
})
expect(wrapper.text()).toContain('Count: 0')
})
})---
Setting Initial State
const wrapper = mount(Counter, {
global: {
plugins: [
createTestingPinia({
initialState: {
counter: { count: 20 }
}
})
]
}
})---
Stubbing Actions
Default: All Actions Stubbed
createTestingPinia() // Actions don't executeExecute All Actions
createTestingPinia({ stubActions: false })Selective Stubbing
createTestingPinia({
stubActions: ['increment', 'reset'] // Only these are stubbed
})Custom Stubbing Logic
createTestingPinia({
stubActions: (actionName, store) => {
return actionName.startsWith('set') // Stub setters only
}
})---
Mocking Getters
const store = useCounterStore()
// Override getter
store.doubleCount = 999
// Reset to default
store.doubleCount = undefined---
Vitest Spy Setup
import { vi } from 'vitest'
createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
// Then mock specific actions
const store = useCounterStore()
vi.spyOn(store, 'fetchData').mockResolvedValue({ data: [] })---
Testing Async Actions
import { flushPromises } from '@vue/test-utils'
it('fetches users', async () => {
const store = useUserStore()
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve([{ id: 1, name: 'Alice' }])
})
await store.fetchUsers()
await flushPromises()
expect(store.users).toHaveLength(1)
expect(store.users[0].name).toBe('Alice')
})---
Testing Store Subscriptions
it('calls subscription on state change', () => {
const store = useCounterStore()
const callback = vi.fn()
store.$subscribe(callback)
store.count = 5
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
storeId: 'counter'
}),
expect.objectContaining({
count: 5
})
)
})---
Testing Action Subscriptions
it('calls action subscription', async () => {
const store = useCounterStore()
const afterCallback = vi.fn()
store.$onAction(({ after }) => {
after(afterCallback)
})
await store.increment()
expect(afterCallback).toHaveBeenCalled()
})---
Testing Store Composition
it('uses other stores', () => {
const authStore = useAuthStore()
const userStore = useUserStore()
authStore.isAuthenticated = true
userStore.loadUserData()
// Verify interaction
expect(userStore.data).toBeDefined()
})---
Testing with Router
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: Home }]
})
const wrapper = mount(App, {
global: {
plugins: [createTestingPinia(), router]
}
})
// Test navigation
await router.push('/')
await router.isReady()---
Testing SSR Stores
Server-Side Test
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import { renderToString } from '@vue/server-renderer'
describe('SSR', () => {
it('renders store state', async () => {
const pinia = createPinia()
const app = createSSRApp({
setup() {
const store = useStore()
store.count = 5
return () => h('div', store.count)
}
})
app.use(pinia)
const html = await renderToString(app)
expect(html).toContain('5')
})
})Hydration Test
it('hydrates state correctly', () => {
const pinia = createPinia()
// Simulate server state
pinia.state.value = {
counter: { count: 10 }
}
setActivePinia(pinia)
const store = useCounterStore()
expect(store.count).toBe(10)
})---
Testing Error Handling
it('handles fetch errors', async () => {
const store = useUserStore()
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
await store.fetchUsers()
expect(store.error).toBe('Network error')
expect(store.loading).toBe(false)
})---
Snapshot Testing
it('matches snapshot', () => {
const store = useCounterStore()
expect({
count: store.count,
doubleCount: store.doubleCount
}).toMatchSnapshot()
})---
Testing TypeScript Types
import { expectType } from 'tsd'
it('has correct types', () => {
const store = useCounterStore()
expectType<number>(store.count)
expectType<number>(store.doubleCount)
expectType<() => void>(store.increment)
})---
Common Testing Patterns
Pattern 1: Authentication Store Test
describe('Auth Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('logs in user', async () => {
const store = useAuthStore()
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: 'abc123', user: { name: 'Alice' } })
})
await store.login('alice@example.com', 'password')
expect(store.token).toBe('abc123')
expect(store.user.name).toBe('Alice')
expect(store.isAuthenticated).toBe(true)
})
it('handles login failure', async () => {
const store = useAuthStore()
global.fetch = vi.fn().mockResolvedValue({
ok: false
})
await expect(store.login('invalid', 'wrong')).rejects.toThrow()
expect(store.token).toBeNull()
expect(store.isAuthenticated).toBe(false)
})
it('logs out user', () => {
const store = useAuthStore()
store.token = 'abc123'
store.user = { name: 'Alice' }
store.logout()
expect(store.token).toBeNull()
expect(store.user).toBeNull()
expect(store.isAuthenticated).toBe(false)
})
})Pattern 2: Data Loading Store Test
describe('Products Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('fetches products successfully', async () => {
const store = useProductsStore()
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve([
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' }
])
})
await store.fetchProducts()
expect(store.products).toHaveLength(2)
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('handles fetch error', async () => {
const store = useProductsStore()
global.fetch = vi.fn().mockRejectedValue(new Error('API Error'))
await store.fetchProducts()
expect(store.products).toHaveLength(0)
expect(store.loading).toBe(false)
expect(store.error).toBe('API Error')
})
it('finds product by id', () => {
const store = useProductsStore()
store.products = [
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' }
]
const product = store.getProductById(2)
expect(product).toEqual({ id: 2, name: 'Product 2' })
})
})---
Testing Best Practices
DO:
- ✅ Create fresh Pinia in
beforeEach() - ✅ Test one behavior per test
- ✅ Mock external dependencies (fetch, localStorage)
- ✅ Test both success and error cases
- ✅ Use
createTestingPinia()for component tests - ✅ Verify side effects (subscriptions, plugins)
DON'T:
- ❌ Share Pinia instances between tests
- ❌ Test implementation details
- ❌ Forget to flush promises for async actions
- ❌ Rely on test execution order
- ❌ Skip error case testing
- ❌ Test framework code (Vue reactivity, etc.)
---
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./test-setup.ts']
}
})// test-setup.ts
import { beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
beforeEach(() => {
// Global Pinia setup for all tests
setActivePinia(createPinia())
})---
See also:
state-getters-actions.mdfor API details to testssr-and-nuxt.mdfor SSR testing patterns
Vuex to Pinia Migration Checklist
Complete checklist for migrating from Vuex to Pinia safely and efficiently.
---
Pre-Migration Preparation
- [ ] Document current Vuex setup
- List all modules and their responsibilities
- Document any complex state dependencies
- Note any Vuex plugins in use
- [ ] Install Pinia alongside Vuex
bun add pinia
# or: npm install pinia- [ ] Set up Pinia instance
// main.ts
import { createPinia } from 'pinia'
const pinia = createPinia()
app.use(pinia) // Can coexist with Vuex initially- [ ] Choose migration strategy
- [ ] Big bang (all at once)
- [ ] Incremental (module by module)
- [ ] Recommended: Incremental migration
---
Module-by-Module Migration
Step 1: Directory Structure
Before (Vuex):
src/store/
├── index.js
└── modules/
├── auth/
│ └── index.js
├── cart/
│ └── index.js
└── products/
└── index.jsAfter (Pinia):
src/stores/
├── auth.ts
├── cart.ts
└── products.ts- [ ] Create
src/stores/directory - [ ] Plan naming convention:
use[Module]Store
Step 2: Module Conversion
For each Vuex module, complete this checklist:
- [ ] Create Pinia store file
- File:
stores/[module-name].ts - Export:
use[ModuleName]Store
- [ ] Convert state
- [ ] Change from object to function:
state: () => ({ ... }) - [ ] Declare all properties (no dynamic properties)
- [ ] Add TypeScript interfaces
- [ ] Convert getters
- [ ] Remove identity getters (direct state access)
- [ ] Convert
rootState/rootGettersto store imports - [ ] Add return types for getters using
this
- [ ] Convert actions
- [ ] Remove
{ commit, dispatch, rootState }parameter - [ ] Change
commit('MUTATION')tothis.property = value - [ ] Convert
dispatch('module/action')touseOtherStore().action() - [ ] Change
rootState.moduletouseModuleStore()
- [ ] Delete mutations
- [ ] Move logic to actions
- [ ] Use direct state mutation:
this.count++ - [ ] Use
$patch()for multiple mutations
- [ ] Add HMR support
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useMyStore, import.meta.hot))
}Step 3: Component Updates
For each component using the migrated module:
- [ ] Composition API components
// Before
import { useStore } from 'vuex'
const store = useStore()
const user = computed(() => store.state.auth.user)
// After
import { useAuthStore } from '@/stores/auth'
import { storeToRefs } from 'pinia'
const auth = useAuthStore()
const { user } = storeToRefs(auth)- [ ] Options API components
// Before
import { mapState, mapActions } from 'vuex'
computed: {
...mapState('auth', ['user'])
}
// After
import { mapState, mapActions } from 'pinia'
import { useAuthStore } from '@/stores/auth'
computed: {
...mapState(useAuthStore, ['user'])
}- [ ] Update all imports
- [ ] Test component functionality
---
Common Conversion Patterns
Pattern 1: Namespaced Modules
Vuex:
// store/modules/user.js
export default {
namespaced: true,
state: () => ({ ... }),
mutations: { ... },
actions: { ... }
}Pinia:
// stores/user.ts
export const useUserStore = defineStore('user', {
state: () => ({ ... }),
actions: { ... } // No namespaced needed - built-in
})- [ ] Remove
namespaced: true - [ ] Store ID is the namespace
Pattern 2: Root State Access
Vuex:
getters: {
userData(state, getters, rootState) {
return rootState.settings.theme + state.name
}
}Pinia:
import { useSettingsStore } from './settings'
getters: {
userData(state) {
const settings = useSettingsStore()
return settings.theme + state.name
}
}- [ ] Import other stores
- [ ] Replace
rootState.xwithuseXStore()
Pattern 3: Mutations → Direct Mutations
Vuex:
mutations: {
SET_USER(state, user) {
state.user = user
},
INCREMENT(state) {
state.count++
}
}
actions: {
updateUser({ commit }, user) {
commit('SET_USER', user)
}
}Pinia:
actions: {
updateUser(user) {
this.user = user // Direct mutation
},
increment() {
this.count++
}
}- [ ] Delete all mutations
- [ ] Move mutation logic to actions
- [ ] Use
this.property = value
Pattern 4: Module Registration
Vuex:
// store/index.js
import user from './modules/user'
import cart from './modules/cart'
export default createStore({
modules: {
user,
cart
}
})Pinia:
// No central registration needed!
// Just import stores where needed
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'- [ ] Remove module registration
- [ ] Stores auto-register on first use
---
Testing Migration
- [ ] Unit test stores
import { setActivePinia, createPinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
describe('Auth Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('logs in user', async () => {
const auth = useAuthStore()
await auth.login({ email: 'test@test.com', password: '123' })
expect(auth.isAuthenticated).toBe(true)
})
})- [ ] Update component tests
import { createTestingPinia } from '@pinia/testing'
mount(MyComponent, {
global: {
plugins: [createTestingPinia()]
}
})---
Router Integration Updates
- [ ] Update navigation guards
Vuex:
import store from '@/store'
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !store.state.auth.isAuthenticated) {
return '/login'
}
})Pinia:
import { useAuthStore } from '@/stores/auth'
router.beforeEach((to, from) => {
const auth = useAuthStore() // ✅ Call inside guard
if (to.meta.requiresAuth && !auth.isAuthenticated) {
return '/login'
}
})---
Plugin Migration
- [ ] Audit Vuex plugins
- List all plugins currently in use
- Check if Pinia equivalents exist
- Plan custom plugin conversions
- [ ] Common plugin conversions
Vuex Persistence:
// Vuex
import createPersistedState from 'vuex-persistedstate'
createStore({
plugins: [createPersistedState()]
})Pinia Persistence:
// Pinia
import { persistPlugin } from '@/plugins/persist'
const pinia = createPinia()
pinia.use(persistPlugin)---
Performance Optimization
- [ ] Enable DevTools
- Verify Pinia appears in Vue DevTools
- Test time-travel debugging
- [ ] Add HMR to all stores
- Faster development iteration
- Preserve state during edits
- [ ] Remove unused code
- Delete Vuex store files
- Remove Vuex from package.json (when fully migrated)
- Remove Vuex setup from main.ts
---
Final Cleanup
- [ ] Remove Vuex completely
bun remove vuex
# or: npm uninstall vuex- [ ] Delete Vuex files
- Remove
store/directory - Remove Vuex TypeScript declarations
- [ ] Update documentation
- Update README with Pinia setup
- Document new store patterns
- Update contribution guidelines
- [ ] Team training
- Share Pinia best practices
- Review common pitfalls
- Demonstrate new patterns
---
Verification Checklist
- [ ] All components work correctly
- [ ] All tests pass
- [ ] No console errors or warnings
- [ ] DevTools shows all stores
- [ ] HMR works in development
- [ ] SSR hydration works (if applicable)
- [ ] Production build succeeds
- [ ] No Vuex dependencies remain
---
Common Migration Gotchas
❌ Don't destructure without storeToRefs()
// ❌ Wrong
const { user } = useAuthStore()
// ✅ Correct
const { user } = storeToRefs(useAuthStore())❌ Don't call useStore() at module level
// ❌ Wrong
const auth = useAuthStore()
router.beforeEach(() => { ... })
// ✅ Correct
router.beforeEach(() => {
const auth = useAuthStore()
})❌ Don't forget to return all state in setup stores
// ❌ Wrong
export const useStore = defineStore('store', () => {
const secret = ref('private') // Not returned!
return {}
})
// ✅ Correct
export const useStore = defineStore('store', () => {
const secret = ref('private')
return { secret } // Exposed
})---
Migration Timeline Example
Week 1:
- [ ] Set up Pinia alongside Vuex
- [ ] Migrate 1-2 simple modules
- [ ] Update related components
- [ ] Test thoroughly
Week 2:
- [ ] Migrate 3-4 more modules
- [ ] Update all related components
- [ ] Migrate router guards
- [ ] Update tests
Week 3:
- [ ] Migrate remaining modules
- [ ] Complete component updates
- [ ] Migrate plugins
- [ ] Final testing
Week 4:
- [ ] Remove Vuex entirely
- [ ] Final verification
- [ ] Deploy to production
- [ ] Monitor for issues
---
Completed: [ ] Migration fully complete and verified
Notes:
- Take your time with each module
- Test thoroughly at each step
- Keep Vuex and Pinia coexisting during migration
- Roll back quickly if issues arise
Migrating from Vuex to Pinia
Complete guide for migrating existing Vuex applications to Pinia.
Last Updated: 2025-11-21
---
Directory Structure Change
Vuex:
src/store/
├── index.js
└── modules/
├── user.js
├── cart.js
└── nested/
└── settings.jsPinia:
src/stores/
├── user.ts
├── cart.ts
└── nested-settings.tsKey difference: Each module becomes an independent store.
---
Conversion Steps
1. Remove Module Namespacing
Module namespacing is built into Pinia store IDs.
Vuex:
// modules/user.js
export default {
namespaced: true,
state: () => ({ ... })
}Pinia:
// stores/user.ts
export const useUserStore = defineStore('user', {
state: () => ({ ... })
})---
2. Convert State to Function
Pinia requires state to be a function.
Vuex (object state):
state: {
firstName: '',
lastName: ''
}Pinia (function state):
state: () => ({
firstName: '',
lastName: ''
})---
3. Remove Identity Getters
Vuex often had getters that just returned state.
Vuex:
// ❌ Remove these
getters: {
firstName: state => state.firstName
}Pinia: In Pinia, just access store.firstName directly. No getter needed.
---
4. Replace rootState/rootGetters
Use direct store imports instead of root access.
Vuex:
getters: {
fullData(state, getters, rootState, rootGetters) {
return rootGetters['otherModule/data'] + state.local
}
}Pinia:
import { useOtherStore } from './other'
getters: {
fullData(state) {
const other = useOtherStore()
return other.data + state.local
}
}---
5. Convert Actions
Remove context parameter and use direct mutations.
Vuex:
actions: {
updateUser({ commit, state, dispatch, rootState }, payload) {
commit('SET_USER', payload)
dispatch('otherModule/action', null, { root: true })
}
}Pinia:
import { useOtherStore } from './other'
actions: {
updateUser(payload) {
this.user = payload // Direct mutation
const other = useOtherStore()
other.someAction() // Direct call
}
}---
6. Eliminate Mutations
Pinia doesn't need mutations - mutate state directly.
Vuex:
mutations: {
SET_USER(state, user) {
state.user = user
}
}
actions: {
updateUser({ commit }, user) {
commit('SET_USER', user)
}
}Pinia (Option 1 - Action mutation):
actions: {
updateUser(user) {
this.user = user // No mutation needed
}
}Pinia (Option 2 - Component mutation):
// Component
store.user = newUser // Directly mutate (acceptable in Pinia)---
7. Use $reset() Instead of Custom Clear
Vuex:
mutations: {
CLEAR_STATE(state) {
state.user = null
state.data = []
}
}Pinia:
store.$reset() // Returns to initial state---
Component Migration
Composition API
Vuex:
<script>
import { mapState, mapActions } from 'vuex'
export default {
computed: {
...mapState('user', ['firstName', 'lastName'])
},
methods: {
...mapActions('user', ['updateUser'])
}
}
</script>Pinia:
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { firstName, lastName } = storeToRefs(userStore)
const { updateUser } = userStore
</script>---
Options API
Vuex:
<script>
import { mapState, mapActions } from 'vuex'
export default {
computed: {
...mapState('user', ['firstName', 'lastName'])
},
methods: {
...mapActions('user', ['updateUser'])
}
}
</script>Pinia:
<script>
import { mapState, mapActions } from 'pinia'
import { useUserStore } from '@/stores/user'
export default {
computed: {
...mapState(useUserStore, ['firstName', 'lastName'])
},
methods: {
...mapActions(useUserStore, ['updateUser'])
}
}
</script>---
Complete Migration Example
Vuex Store
// store/modules/user.js
export default {
namespaced: true,
state: {
firstName: '',
lastName: '',
age: 0
},
getters: {
fullName: state => `${state.firstName} ${state.lastName}`,
isAdult: state => state.age >= 18
},
mutations: {
SET_FIRST_NAME(state, name) {
state.firstName = name
},
SET_LAST_NAME(state, name) {
state.lastName = name
},
SET_AGE(state, age) {
state.age = age
}
},
actions: {
async loadUser({ commit }, userId) {
const response = await fetch(`/api/users/${userId}`)
const user = await response.json()
commit('SET_FIRST_NAME', user.firstName)
commit('SET_LAST_NAME', user.lastName)
commit('SET_AGE', user.age)
},
updateFirstName({ commit }, name) {
commit('SET_FIRST_NAME', name)
}
}
}Pinia Store (Option Store)
// stores/user.ts
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
firstName: '',
lastName: '',
age: 0
}),
getters: {
fullName: (state) => `${state.firstName} ${state.lastName}`,
isAdult: (state) => state.age >= 18
},
actions: {
async loadUser(userId: number) {
const response = await fetch(`/api/users/${userId}`)
const user = await response.json()
// Direct mutations - no commits needed
this.firstName = user.firstName
this.lastName = user.lastName
this.age = user.age
},
updateFirstName(name: string) {
this.firstName = name
}
}
})Pinia Store (Setup Store)
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// State
const firstName = ref('')
const lastName = ref('')
const age = ref(0)
// Getters
const fullName = computed(() => `${firstName.value} ${lastName.value}`)
const isAdult = computed(() => age.value >= 18)
// Actions
async function loadUser(userId: number) {
const response = await fetch(`/api/users/${userId}`)
const user = await response.json()
firstName.value = user.firstName
lastName.value = user.lastName
age.value = user.age
}
function updateFirstName(name: string) {
firstName.value = name
}
return {
firstName,
lastName,
age,
fullName,
isAdult,
loadUser,
updateFirstName
}
})---
Vuex Features and Pinia Equivalents
| Vuex Feature | Pinia Equivalent |
|---|---|
state | state() (option) or ref() (setup) |
getters | getters (option) or computed() (setup) |
mutations | ❌ Not needed - mutate directly |
actions | actions (option) or function() (setup) |
namespaced: true | Automatic (store ID) |
rootState | Import other stores |
rootGetters | Import other stores |
commit() | Direct mutation |
dispatch() | Call action directly |
mapState | storeToRefs() or mapState() |
mapGetters | storeToRefs() or mapState() |
mapMutations | ❌ Not needed |
mapActions | mapActions() or destructure |
modules | Independent stores |
registerModule | Create new store dynamically |
---
Migration Checklist
- [ ] Install Pinia:
bun add pinia - [ ] Create
stores/directory - [ ] Convert each Vuex module to a Pinia store
- [ ] Remove
namespacedproperty (automatic in Pinia) - [ ] Convert state to function if needed
- [ ] Remove identity getters
- [ ] Replace
rootState/rootGetterswith store imports - [ ] Remove mutations, merge into actions
- [ ] Update actions to mutate state directly
- [ ] Convert components from Vuex helpers to Pinia
- [ ] Replace
commit()calls with direct mutations - [ ] Replace
dispatch()calls with direct action calls - [ ] Test all functionality
- [ ] Remove Vuex from
package.json - [ ] Delete
store/directory
---
Common Migration Pitfalls
Pitfall 1: Forgetting to Use storeToRefs()
<!-- ❌ Wrong - loses reactivity -->
<script setup>
const { firstName, lastName } = useUserStore()
</script>
<!-- ✅ Correct - maintains reactivity -->
<script setup>
import { storeToRefs } from 'pinia'
const { firstName, lastName } = storeToRefs(useUserStore())
</script>Pitfall 2: Trying to Commit Mutations
// ❌ Vuex habits die hard
actions: {
updateUser(user) {
this.commit('SET_USER', user) // No commit in Pinia!
}
}
// ✅ Mutate directly
actions: {
updateUser(user) {
this.user = user
}
}Pitfall 3: Not Converting Nested Modules
Don't forget to convert deeply nested Vuex modules:
store/modules/user/profile.js → stores/user-profile.ts---
Gradual Migration Strategy
Step 1: Install Pinia Alongside Vuex
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import store from './store' // Vuex store
const app = createApp(App)
const pinia = createPinia()
app.use(store) // Keep Vuex
app.use(pinia) // Add Pinia
app.mount('#app')Step 2: Migrate One Module at a Time
Convert one Vuex module to Pinia, test thoroughly, then move to next module.
Step 3: Remove Vuex When Complete
// Remove Vuex
// app.use(store)
// Keep only Pinia
app.use(pinia)---
Benefits After Migration
What you gain:
- ✅ Simpler API (no mutations, no namespacing)
- ✅ Better TypeScript support
- ✅ Smaller bundle size
- ✅ Automatic code splitting per store
- ✅ DevTools with time-travel debugging
- ✅ Hot module replacement (HMR)
- ✅ Plugin system
- ✅ SSR support out of the box
What you lose:
- ❌ Nothing! Pinia is a complete replacement
---
See also:
store-syntax-guide.mdfor choosing Option vs Setup storesstate-getters-actions.mdfor complete Pinia API
// API Data Store Example
// Production-ready pattern for managing API data with loading states
import { defineStore } from 'pinia'
import { acceptHMRUpdate } from 'pinia'
interface Todo {
id: number
title: string
completed: boolean
userId: number
}
interface TodosState {
todos: Todo[]
loading: boolean
error: string | null
selectedTodo: Todo | null
}
export const useTodosStore = defineStore('todos', {
state: (): TodosState => ({
todos: [],
loading: false,
error: null,
selectedTodo: null
}),
getters: {
// Total count
todoCount: (state) => state.todos.length,
// Filtered lists
completedTodos: (state) => state.todos.filter(t => t.completed),
activeTodos: (state) => state.todos.filter(t => !t.completed),
// Counts
completedCount(): number {
return this.completedTodos.length
},
activeCount(): number {
return this.activeTodos.length
},
// Get by ID
getTodoById: (state) => {
return (id: number) => state.todos.find(t => t.id === id)
},
// Get by user ID
getTodosByUserId: (state) => {
return (userId: number) => state.todos.filter(t => t.userId === userId)
}
},
actions: {
// FETCH ALL
async fetchTodos() {
this.loading = true
this.error = null
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
this.todos = await response.json()
} catch (e) {
this.error = e instanceof Error ? e.message : 'Failed to fetch todos'
console.error('Error fetching todos:', e)
} finally {
this.loading = false
}
},
// FETCH ONE
async fetchTodo(id: number) {
this.loading = true
this.error = null
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const todo = await response.json()
// Update in list if exists, otherwise add
const index = this.todos.findIndex(t => t.id === id)
if (index !== -1) {
this.todos[index] = todo
} else {
this.todos.push(todo)
}
this.selectedTodo = todo
} catch (e) {
this.error = e instanceof Error ? e.message : 'Failed to fetch todo'
console.error('Error fetching todo:', e)
} finally {
this.loading = false
}
},
// CREATE
async createTodo(todo: Omit<Todo, 'id'>) {
this.loading = true
this.error = null
try {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(todo)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const newTodo = await response.json()
// Add to local state
this.todos.push(newTodo)
return { success: true, todo: newTodo }
} catch (e) {
this.error = e instanceof Error ? e.message : 'Failed to create todo'
console.error('Error creating todo:', e)
return { success: false, error: this.error }
} finally {
this.loading = false
}
},
// UPDATE
async updateTodo(id: number, updates: Partial<Todo>) {
this.loading = true
this.error = null
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updates)
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const updatedTodo = await response.json()
// Update in local state
const index = this.todos.findIndex(t => t.id === id)
if (index !== -1) {
this.todos[index] = { ...this.todos[index], ...updatedTodo }
}
if (this.selectedTodo?.id === id) {
this.selectedTodo = { ...this.selectedTodo, ...updatedTodo }
}
return { success: true, todo: updatedTodo }
} catch (e) {
this.error = e instanceof Error ? e.message : 'Failed to update todo'
console.error('Error updating todo:', e)
return { success: false, error: this.error }
} finally {
this.loading = false
}
},
// DELETE
async deleteTodo(id: number) {
this.loading = true
this.error = null
try {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
// Remove from local state
const index = this.todos.findIndex(t => t.id === id)
if (index !== -1) {
this.todos.splice(index, 1)
}
if (this.selectedTodo?.id === id) {
this.selectedTodo = null
}
return { success: true }
} catch (e) {
this.error = e instanceof Error ? e.message : 'Failed to delete todo'
console.error('Error deleting todo:', e)
return { success: false, error: this.error }
} finally {
this.loading = false
}
},
// TOGGLE COMPLETED
async toggleCompleted(id: number) {
const todo = this.todos.find(t => t.id === id)
if (!todo) return
return this.updateTodo(id, { completed: !todo.completed })
},
// LOCAL STATE MUTATIONS
setSelectedTodo(todo: Todo | null) {
this.selectedTodo = todo
},
clearError() {
this.error = null
}
}
})
// HMR Support
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useTodosStore, import.meta.hot))
}
// Usage in component:
//
// <script setup>
// import { useTodosStore } from '@/stores/todos'
// import { storeToRefs } from 'pinia'
//
// const todosStore = useTodosStore()
// const { todos, loading, error, completedCount, activeCount } = storeToRefs(todosStore)
// const { fetchTodos, createTodo, toggleCompleted } = todosStore
//
// // Fetch on mount
// onMounted(() => {
// fetchTodos()
// })
// </script>
//
// <template>
// <div>
// <div v-if="loading">Loading...</div>
// <div v-else-if="error">Error: {{ error }}</div>
// <div v-else>
// <p>Total: {{ todos.length }} | Completed: {{ completedCount }} | Active: {{ activeCount }}</p>
// <ul>
// <li v-for="todo in todos" :key="todo.id">
// <input
// type="checkbox"
// :checked="todo.completed"
// @change="toggleCompleted(todo.id)"
// />
// {{ todo.title }}
// </li>
// </ul>
// </div>
// </div>
// </template>
// Authentication Store Example
// Production-ready authentication pattern with Pinia
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { acceptHMRUpdate } from 'pinia'
interface User {
id: number
email: string
name: string
role: string
}
interface LoginCredentials {
email: string
password: string
}
interface RegisterData extends LoginCredentials {
name: string
}
export const useAuthStore = defineStore('auth', () => {
// STATE
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// GETTERS
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const userName = computed(() => user.value?.name || 'Guest')
// ACTIONS
async function login(credentials: LoginCredentials) {
loading.value = true
error.value = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Login failed')
}
const data = await response.json()
// Store token and user
token.value = data.token
user.value = data.user
// Persist token to localStorage
localStorage.setItem('auth_token', data.token)
return { success: true }
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
async function register(data: RegisterData) {
loading.value = true
error.value = null
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Registration failed')
}
const result = await response.json()
// Auto-login after registration
token.value = result.token
user.value = result.user
localStorage.setItem('auth_token', result.token)
return { success: true }
} catch (e) {
error.value = e instanceof Error ? e.message : 'Registration failed'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
async function logout() {
try {
// Optional: Call logout endpoint
if (token.value) {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token.value}`
}
})
}
} catch (e) {
console.error('Logout API error:', e)
} finally {
// Clear state regardless of API response
user.value = null
token.value = null
error.value = null
localStorage.removeItem('auth_token')
}
}
async function fetchCurrentUser() {
const savedToken = localStorage.getItem('auth_token')
if (!savedToken) {
return { success: false }
}
loading.value = true
error.value = null
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${savedToken}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch user')
}
const userData = await response.json()
token.value = savedToken
user.value = userData
return { success: true }
} catch (e) {
// Token is invalid, clear it
localStorage.removeItem('auth_token')
token.value = null
user.value = null
error.value = e instanceof Error ? e.message : 'Authentication failed'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
async function updateProfile(updates: Partial<User>) {
if (!token.value) {
error.value = 'Not authenticated'
return { success: false, error: error.value }
}
loading.value = true
error.value = null
try {
const response = await fetch('/api/auth/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token.value}`
},
body: JSON.stringify(updates)
})
if (!response.ok) throw new Error('Failed to update profile')
const updatedUser = await response.json()
user.value = updatedUser
return { success: true }
} catch (e) {
error.value = e instanceof Error ? e.message : 'Update failed'
return { success: false, error: error.value }
} finally {
loading.value = false
}
}
function $reset() {
user.value = null
token.value = null
loading.value = false
error.value = null
}
return {
// State
user,
token,
loading,
error,
// Getters
isAuthenticated,
isAdmin,
userName,
// Actions
login,
register,
logout,
fetchCurrentUser,
updateProfile,
$reset
}
})
// HMR Support
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAuthStore, import.meta.hot))
}
// Usage in router guards:
//
// import { useAuthStore } from '@/stores/auth'
//
// router.beforeEach((to, from) => {
// const auth = useAuthStore() // ✅ Called inside guard
//
// if (to.meta.requiresAuth && !auth.isAuthenticated) {
// return { name: 'login' }
// }
//
// if (to.meta.requiresAdmin && !auth.isAdmin) {
// return { name: 'unauthorized' }
// }
// })
// Usage in main.ts for initial auth check:
//
// const app = createApp(App)
// app.use(pinia)
//
// const auth = useAuthStore()
// await auth.fetchCurrentUser()
//
// app.mount('#app')
#!/bin/bash
# [TODO: Script Name]
# [TODO: Brief description of what this script does]
# Example script structure - delete if not needed
set -e # Exit on error
# [TODO: Add your script logic here]
echo "Example script - replace or delete this file"
# Usage:
# ./scripts/example-script.sh [args]
// Option Store Template
// Copy this template when creating new stores with Options API style
import { defineStore, acceptHMRUpdate } from 'pinia'
// Define TypeScript interfaces for type safety
interface User {
id: number
name: string
email: string
}
interface UserState {
users: User[]
currentUser: User | null
loading: boolean
error: string | null
}
export const useUserStore = defineStore('user', {
// State: Return an object with all state properties
state: (): UserState => ({
users: [],
currentUser: null,
loading: false,
error: null
}),
// Getters: Computed properties (like computed in components)
getters: {
// Arrow function getter with state parameter
userCount: (state) => state.users.length,
// Regular function getter accessing other getters
// IMPORTANT: Must type return value when using 'this'
hasUsers(): boolean {
return this.userCount > 0
},
// Getter that returns a function (for parameters)
getUserById: (state) => {
return (userId: number) => state.users.find(u => u.id === userId)
},
// Getter accessing another store
// import { useSettingsStore } from './settings'
// userWithSettings(state) {
// const settings = useSettingsStore()
// return { ...state.currentUser, theme: settings.theme }
// }
},
// Actions: Methods for business logic and state mutations
actions: {
// Sync action
setCurrentUser(user: User | null) {
this.currentUser = user
},
// Async action with error handling
async fetchUsers() {
this.loading = true
this.error = null
try {
const response = await fetch('/api/users')
if (!response.ok) throw new Error('Failed to fetch users')
this.users = await response.json()
} catch (e) {
this.error = e instanceof Error ? e.message : 'Unknown error'
console.error('Error fetching users:', e)
} finally {
this.loading = false
}
},
// Action calling another store's action
// async fetchUserAndSettings(userId: number) {
// await this.fetchUsers()
// const settings = useSettingsStore()
// await settings.fetchSettings(userId)
// }
// Action with $patch for multiple mutations
resetUserState() {
this.$patch({
users: [],
currentUser: null,
error: null
})
}
// Or use built-in $reset() to restore initial state
// this.$reset()
}
})
// HMR Support (Hot Module Replacement)
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))
}
// Pinia Persistence Plugin Examples
// Automatically persist store state to localStorage/sessionStorage
import { PiniaPluginContext } from 'pinia'
import { watch } from 'vue'
// ============================================
// BASIC PERSISTENCE PLUGIN
// ============================================
/**
* Basic plugin that persists ALL stores to localStorage
*/
export function persistPlugin({ store }: PiniaPluginContext) {
// Guard against SSR environments where localStorage is undefined
if (typeof window === 'undefined' || !window.localStorage) return
// Restore state from localStorage on store initialization
const stored = localStorage.getItem(store.$id)
if (stored) {
try {
store.$patch(JSON.parse(stored))
} catch (e) {
console.error(`Failed to restore store "${store.$id}":`, e)
}
}
// Save state to localStorage on every change
store.$subscribe((mutation, state) => {
try {
localStorage.setItem(store.$id, JSON.stringify(state))
} catch (e) {
console.error(`Failed to persist store "${store.$id}":`, e)
}
})
}
// ============================================
// SELECTIVE PERSISTENCE PLUGIN
// ============================================
/**
* Plugin that only persists stores with `persist: true` option
*
* Usage:
* defineStore('cart', {
* persist: true, // Enable persistence for this store
* state: () => ({ items: [] })
* })
*/
export function selectivePersistPlugin({ options, store }: PiniaPluginContext) {
if (!options.persist) return
// Guard against SSR environments where localStorage is undefined
if (typeof window === 'undefined' || !window.localStorage) return
const storageKey = `pinia_${store.$id}`
// Restore
const stored = localStorage.getItem(storageKey)
if (stored) {
try {
store.$patch(JSON.parse(stored))
} catch (e) {
console.error(`Failed to restore store "${store.$id}":`, e)
}
}
// Persist
store.$subscribe((mutation, state) => {
try {
localStorage.setItem(storageKey, JSON.stringify(state))
} catch (e) {
console.error(`Failed to persist store "${store.$id}":`, e)
}
})
}
// TypeScript declaration for custom option (removed - see advanced plugin declaration below)
// ============================================
// ADVANCED PERSISTENCE PLUGIN
// ============================================
interface PersistOptions {
enabled?: boolean
storage?: 'local' | 'session'
key?: string
paths?: string[] // Specific state paths to persist
debounce?: number // Debounce delay in milliseconds
beforeRestore?: (context: PiniaPluginContext) => void
afterRestore?: (context: PiniaPluginContext) => void
}
/**
* Advanced plugin with fine-grained control
*
* Usage:
* defineStore('user', {
* persist: {
* enabled: true,
* storage: 'local',
* key: 'my-user-store',
* paths: ['user', 'token'], // Only persist these properties
* beforeRestore: () => console.log('Restoring...'),
* afterRestore: () => console.log('Restored!')
* },
* state: () => ({ user: null, token: null, tempData: {} })
* })
*/
export function advancedPersistPlugin({ options, store }: PiniaPluginContext) {
const persist = options.persist as PersistOptions | undefined
if (!persist || !persist.enabled) return
// Guard against SSR environments where storage APIs are undefined
if (typeof window === 'undefined') return
const requestedStorage = persist.storage === 'session' ? 'sessionStorage' : 'localStorage'
if (!window[requestedStorage]) return
const storage = persist.storage === 'session' ? sessionStorage : localStorage
const key = persist.key || `pinia_${store.$id}`
// Helper to get/set specific paths
const getPathValue = (obj: any, path: string) => {
return path.split('.').reduce((acc, part) => acc?.[part], obj)
}
const setPathValue = (obj: any, path: string, value: any) => {
const parts = path.split('.')
const last = parts.pop()!
const target = parts.reduce((acc, part) => {
if (!(part in acc)) acc[part] = {}
return acc[part]
}, obj)
target[last] = value
}
// Restore state
const stored = storage.getItem(key)
if (stored) {
try {
const data = JSON.parse(stored)
if (persist.beforeRestore) {
persist.beforeRestore({ options, store, pinia: store.$pinia, app: store.$app })
}
if (persist.paths) {
// Restore only specific paths
const patch: any = {}
persist.paths.forEach(path => {
const value = getPathValue(data, path)
if (value !== undefined) {
setPathValue(patch, path, value)
}
})
store.$patch(patch)
} else {
// Restore all
store.$patch(data)
}
if (persist.afterRestore) {
persist.afterRestore({ options, store, pinia: store.$pinia, app: store.$app })
}
} catch (e) {
console.error(`Failed to restore store "${store.$id}":`, e)
}
}
// Persist state
store.$subscribe((mutation, state) => {
try {
let dataToStore: any
if (persist.paths) {
// Persist only specific paths
dataToStore = {}
persist.paths.forEach(path => {
const value = getPathValue(state, path)
if (value !== undefined) {
setPathValue(dataToStore, path, value)
}
})
} else {
// Persist all
dataToStore = state
}
storage.setItem(key, JSON.stringify(dataToStore))
} catch (e) {
console.error(`Failed to persist store "${store.$id}":`, e)
}
})
}
// TypeScript declaration
declare module 'pinia' {
export interface DefineStoreOptionsBase<S, Store> {
persist?: boolean | PersistOptions
}
}
// ============================================
// DEBOUNCED PERSISTENCE PLUGIN
// ============================================
/**
* Plugin that debounces persistence to reduce localStorage writes
*
* Useful for stores that change frequently
*/
export function debouncedPersistPlugin({ options, store }: PiniaPluginContext) {
if (!options.persist) return
// Guard against SSR environments where localStorage is undefined
if (typeof window === 'undefined' || !window.localStorage) return
const storageKey = `pinia_${store.$id}`
const debounceMs = typeof options.persist === 'object'
? options.persist.debounce || 500
: 500
// Restore
const stored = localStorage.getItem(storageKey)
if (stored) {
try {
store.$patch(JSON.parse(stored))
} catch (e) {
console.error(`Failed to restore store "${store.$id}":`, e)
}
}
// Debounced persist
let timeout: ReturnType<typeof setTimeout> | null = null
store.$subscribe((mutation, state) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
try {
localStorage.setItem(storageKey, JSON.stringify(state))
} catch (e) {
console.error(`Failed to persist store "${store.$id}":`, e)
}
}, debounceMs)
})
}
// ============================================
// USAGE IN main.ts
// ============================================
/*
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// Import your chosen plugin
import { advancedPersistPlugin } from './plugins/persistence'
const pinia = createPinia()
// Register plugin
pinia.use(advancedPersistPlugin)
const app = createApp(App)
app.use(pinia)
app.mount('#app')
*/
// ============================================
// EXAMPLE STORE WITH PERSISTENCE
// ============================================
/*
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
persist: {
enabled: true,
storage: 'local',
paths: ['items', 'total'] // Only persist items and total, not loading states
},
state: () => ({
items: [],
total: 0,
loading: false, // Won't be persisted
error: null // Won't be persisted
}),
actions: {
addItem(item) {
this.items.push(item)
this.total += item.price
}
}
})
*/
// Setup Store Template
// Copy this template when creating new stores with Composition API style
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { acceptHMRUpdate } from 'pinia'
// Define TypeScript interfaces
interface Product {
id: number
name: string
price: number
quantity: number
}
export const useProductStore = defineStore('product', () => {
// STATE: Use ref() for reactive state
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// GETTERS: Use computed() for derived state
const productCount = computed(() => products.value.length)
const totalValue = computed(() => {
return products.value.reduce((sum, p) => sum + (p.price * p.quantity), 0)
})
const hasProducts = computed(() => productCount.value > 0)
// Getter that takes parameters (return a function)
const getProductById = computed(() => {
return (id: number) => products.value.find(p => p.id === id)
})
// ACTIONS: Use function() for business logic
function addProduct(product: Product) {
products.value.push(product)
}
function removeProduct(id: number) {
const index = products.value.findIndex(p => p.id === id)
if (index !== -1) {
products.value.splice(index, 1)
}
}
async function fetchProducts() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/products')
if (!response.ok) throw new Error('Failed to fetch products')
products.value = await response.json()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Unknown error'
console.error('Error fetching products:', e)
} finally {
loading.value = false
}
}
// Action calling another store
// async function fetchProductsWithSettings() {
// const settings = useSettingsStore()
//
// // CRITICAL: Call useStore() BEFORE any await
// // This prevents SSR issues
// await fetchProducts()
//
// if (settings.showPrices) {
// // Use settings after await is safe
// }
// }
// Custom $reset() implementation (setup stores don't have built-in reset)
function $reset() {
products.value = []
loading.value = false
error.value = null
}
// MUST return all properties you want to expose
// Private properties (not returned) break SSR, DevTools, and plugins
return {
// State
products,
loading,
error,
// Getters
productCount,
totalValue,
hasProducts,
getProductById,
// Actions
addProduct,
removeProduct,
fetchProducts,
$reset
}
})
// HMR Support
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useProductStore, import.meta.hot))
}