
Vue Pinia Best Practices
- 3k installs
- 2.8k repo stars
- Updated May 30, 2026
- hyf0/vue-skills
This is a copy of vue-pinia-best-practices by vuejs-ai - installs and ranking accrue to the original listing.
vue-pinia-best-practices is an agent skill that eliminates the "getActivePinia was called but there was no active Pinia" runtime error in Vue 3 applications for developers who initialize Pinia stores during app setup.
About
vue-pinia-best-practices is a Vue 3 agent skill from hyf0/vue-skills tagged as a high-impact gotcha fix for Pinia state management. The skill diagnoses the common error when stores are accessed before Pinia is installed on the Vue app instance. It enforces calling app.use(pinia) before app.mount() and corrects store usage timing in setup blocks, plugins, and router guards. Developers reach for vue-pinia-best-practices when a Vue 3 app crashes with getActivePinia() errors after adding Pinia stores, composables, or early lifecycle hooks that reference useStore() too soon.
- 5-point task checklist that prevents store initialization crashes
- Enforces correct plugin order: app.use(pinia) before app.use(router) and app.mount()
- Prevents calling useXxxStore() at module top-level or before app initialization
- Handles differences between <script setup> and <script> execution timing
- HIGH impact fix that stops application crashes on first load
Vue Pinia Best Practices by the numbers
- 2,984 all-time installs (skills.sh)
- +51 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hyf0/vue-skills --skill vue-pinia-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3k |
|---|---|
| repo stars | ★ 2.8k |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 30, 2026 |
| Repository | hyf0/vue-skills ↗ |
How do you fix no active Pinia error in Vue 3?
Eliminate the common "getActivePinia was called but there was no active Pinia" runtime error in Vue 3 applications.
Who is it for?
Vue 3 developers hitting getActivePinia() crashes during Pinia setup, composables, or router initialization.
Skip if: React or Angular state management projects or Vue 2 applications not using Pinia.
When should I use this skill?
User reports getActivePinia was called but there was no active Pinia or asks about Pinia initialization order in Vue 3.
What you get
Corrected Pinia plugin install order and store access timing with a passing Vue 3 app boot
- Corrected main.ts/bootstrap Pinia install order
- Fixed store access timing in affected modules
By the numbers
- Marked HIGH impact gotcha in the vue-skills catalog
Files
Pinia best practices, common gotchas, and state management patterns.
Store Setup
- Getting "getActivePinia was called" error at startup → See pinia-no-active-pinia-error
- Setup stores missing state in DevTools or SSR → See pinia-setup-store-return-all-state
Reactivity
- Store destructuring stops updating UI reactively → See pinia-store-destructuring-breaks-reactivity
- Store methods lose context in template calls → See store-method-binding-parentheses
State Patterns
- Filters reset on refresh or can't be shared → See state-url-for-ephemeral-filters
- Building production app without DevTools or conventions → See state-use-pinia-for-large-apps
Fix "No Active Pinia" Error - Store Setup Timing
Impact: HIGH - The error "getActivePinia() was called but there was no active Pinia" is one of the most common Pinia errors. It occurs when you try to use a store before Pinia has been installed on the Vue app, causing your application to crash.
Task Checklist
- [ ] Ensure
app.use(pinia)is called beforeapp.mount() - [ ] Ensure
app.use(pinia)is called beforeapp.use(router)if router guards use stores - [ ] Never call
useXxxStore()in module-level (top-level) code - [ ] Only call
useXxxStore()inside setup functions, composables, or after app initialization - [ ] Check for
<script setup>vs<script>- the latter runs too early
The Error
[🍍]: "getActivePinia()" was called but there was no active Pinia.
Did you forget to install pinia?Common Cause 1: Wrong Plugin Order
// main.js - WRONG ORDER
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router' // Router uses a store in navigation guard
import App from './App.vue'
const app = createApp(App)
// WRONG: Router is installed first, but its guards use stores
app.use(router) // Router guard calls useAuthStore() - FAILS!
app.use(createPinia())
app.mount('#app')Fix: Install Pinia first:
// main.js - CORRECT ORDER
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
const app = createApp(App)
// CORRECT: Pinia installed before anything that uses stores
app.use(createPinia())
app.use(router) // Now router guards can safely use stores
app.mount('#app')Common Cause 2: Store Used at Module Level
// api.js - WRONG: Module-level store usage
import { useAuthStore } from '@/stores/auth'
// This runs immediately when the module is imported!
const authStore = useAuthStore() // ERROR: No active Pinia yet
export function fetchUser() {
return fetch('/api/user', {
headers: {
Authorization: `Bearer ${authStore.token}`
}
})
}Fix: Call useStore inside functions:
// api.js - CORRECT: Store used inside function
import { useAuthStore } from '@/stores/auth'
export function fetchUser() {
// Store is accessed when function is called, not when module loads
const authStore = useAuthStore()
return fetch('/api/user', {
headers: {
Authorization: `Bearer ${authStore.token}`
}
})
}Common Cause 3: Script Tag Missing "setup"
<!-- WRONG: <script> runs before component setup -->
<script>
import { useUserStore } from '@/stores/user'
// This runs too early, before the component is set up
const userStore = useUserStore() // ERROR!
export default {
// ...
}
</script>Fix: Use `<script setup>` or move to setup function:
<!-- CORRECT: <script setup> runs at the right time -->
<script setup>
import { useUserStore } from '@/stores/user'
// This runs during component setup, Pinia is active
const userStore = useUserStore() // Works!
</script><!-- CORRECT: Options API with setup function -->
<script>
import { useUserStore } from '@/stores/user'
export default {
setup() {
// Called during component initialization
const userStore = useUserStore() // Works!
return { userStore }
}
}
</script>Common Cause 4: mapStores with Parentheses
<script>
import { mapStores } from 'pinia'
import { useProductsStore } from '@/stores/products'
export default {
computed: {
// WRONG: Called the function instead of passing it
...mapStores(useProductsStore()) // ERROR!
}
}
</script>Fix: Pass the function reference, not the result:
<script>
import { mapStores } from 'pinia'
import { useProductsStore } from '@/stores/products'
export default {
computed: {
// CORRECT: Pass the function without calling it
...mapStores(useProductsStore) // No parentheses!
}
}
</script>Common Cause 5: Router Guards Before Pinia
// router/index.js - WRONG
import { createRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({ /* ... */ })
// This guard is registered immediately
router.beforeEach((to) => {
// When this runs during app startup, Pinia might not be ready
const authStore = useAuthStore() // May fail!
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return '/login'
}
})Fix: Use lazy store access or ensure plugin order:
// router/index.js - CORRECT
import { createRouter } from 'vue-router'
const router = createRouter({ /* ... */ })
router.beforeEach((to) => {
// Dynamically import to avoid module-level execution
const { useAuthStore } = await import('@/stores/auth')
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return '/login'
}
})
// OR ensure main.js has correct order:
// app.use(pinia)
// app.use(router)Debugging Checklist
When you see "No active Pinia":
1. Check main.js order: Is app.use(pinia) before other plugins? 2. Search for top-level useStore calls: Any store usage outside functions/setup? 3. Check script tags: Using <script> instead of <script setup>? 4. Check mapStores usage: Using useStore() instead of useStore? 5. Check import chains: Does an early import trigger store usage?
Safe Pattern: Conditional Store Access
// For code that might run before Pinia is ready
import { getActivePinia } from 'pinia'
export function safelyUseStore() {
const pinia = getActivePinia()
if (!pinia) {
console.warn('Pinia not initialized yet')
return null
}
const { useUserStore } = await import('@/stores/user')
return useUserStore()
}Reference
Return All State Properties in Pinia Setup Stores
Impact: HIGH - When using Pinia's setup store syntax (Composition API style), you MUST return all state properties from the setup function. Private state that isn't returned will break Server-Side Rendering (SSR), Vue DevTools inspection, and Pinia plugins.
This is a critical gotcha that can cause silent failures in production.
Task Checklist
- [ ] Return ALL reactive state properties from setup stores
- [ ] Do not create "private" state by omitting it from the return
- [ ] If you need private state, use a prefix convention instead (e.g.,
_internal) - [ ] Test stores with DevTools to verify all state is visible
- [ ] Verify SSR hydration includes all necessary state
The Problem: Private State in Setup Stores
// stores/user.js - WRONG: Private state breaks SSR/DevTools
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// Public state
const name = ref('')
const email = ref('')
// "Private" state - NOT returned
const authToken = ref('') // Won't be serialized for SSR!
const lastFetchTime = ref(null) // Won't appear in DevTools!
const isLoggedIn = computed(() => !!authToken.value)
async function login(credentials) {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await response.json()
authToken.value = data.token // This state won't transfer to client in SSR!
name.value = data.name
email.value = data.email
lastFetchTime.value = Date.now()
}
// WRONG: Not returning authToken and lastFetchTime
return {
name,
email,
isLoggedIn,
login
}
})What breaks:
1. SSR Hydration: authToken and lastFetchTime won't be serialized and sent to the client 2. DevTools: These properties won't appear in the store inspector 3. Plugins: Persistence plugins won't save these properties 4. Time-travel debugging: Can't track changes to hidden state
The Solution: Return Everything
// stores/user.js - CORRECT: All state returned
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// All state properties
const name = ref('')
const email = ref('')
const authToken = ref('')
const lastFetchTime = ref(null)
// Getters
const isLoggedIn = computed(() => !!authToken.value)
// Actions
async function login(credentials) {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await response.json()
authToken.value = data.token
name.value = data.name
email.value = data.email
lastFetchTime.value = Date.now()
}
function logout() {
authToken.value = ''
name.value = ''
email.value = ''
}
// CORRECT: Return ALL state, getters, and actions
return {
// State
name,
email,
authToken,
lastFetchTime,
// Getters
isLoggedIn,
// Actions
login,
logout
}
})If You Need "Private" State
Use naming conventions instead of actually hiding state:
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// Convention: underscore prefix for "internal" state
// Still returned, but signals it's not for external use
const _authToken = ref('')
const _lastFetchTime = ref(null)
// Public state
const name = ref('')
const email = ref('')
const isLoggedIn = computed(() => !!_authToken.value)
// Return everything - convention communicates intent
return {
// "Private" - use with caution
_authToken,
_lastFetchTime,
// Public
name,
email,
isLoggedIn
}
})Option Stores Don't Have This Problem
With the Options API syntax, all state is automatically tracked:
// stores/user.js - Options syntax: all state is tracked automatically
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
email: '',
authToken: '', // Automatically included
lastFetchTime: null // Automatically included
}),
getters: {
isLoggedIn: (state) => !!state.authToken
},
actions: {
async login(credentials) {
// ...
}
}
})How Setup Stores Map to State
Understanding the mapping helps avoid mistakes:
defineStore('example', () => {
// ref() becomes state
const count = ref(0) // → state.count
// computed() becomes getters
const double = computed(() => count.value * 2) // → getters.double
// Regular functions become actions
function increment() { // → actions.increment
count.value++
}
// CRITICAL: Must return all of them
return { count, double, increment }
})Debugging: Verify All State is Returned
// Test that DevTools can see all state
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// In DevTools console or tests:
console.log(userStore.$state)
// Should include ALL reactive state properties
// Check what's returned
console.log(Object.keys(userStore))
// Should include: name, email, authToken, lastFetchTime, isLoggedIn, login, logoutReference
Use storeToRefs When Destructuring Pinia Stores
Impact: HIGH - Pinia stores are wrapped with reactive, so destructuring them directly extracts non-reactive values. Changes to the store won't be reflected in your component, causing stale UI and confusing bugs.
This is one of the most common mistakes when using Pinia, especially for developers coming from Vuex or other state management libraries.
Task Checklist
- [ ] Never destructure state or getters directly from a Pinia store
- [ ] Use
storeToRefs()to extract reactive state and getters - [ ] Destructure actions directly (they don't need reactivity)
- [ ] Remember:
storeToRefsis for state/getters, direct destructure is for actions
The Problem: Direct Destructuring
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// WRONG: Direct destructuring breaks reactivity
const { name, email, isLoggedIn } = userStore
// Later, when store updates...
userStore.login({ name: 'John', email: 'john@example.com' })
// name and email are still the OLD values!
// UI won't update because these are no longer reactive
console.log(name) // undefined (or initial value)
</script>
<template>
<!-- This won't update when store changes -->
<div>{{ name }}</div>
</template>The Solution: Use storeToRefs
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// CORRECT: Use storeToRefs for state and getters
const { name, email, isLoggedIn } = storeToRefs(userStore)
// Actions can be destructured directly (they're just functions)
const { login, logout } = userStore
// Now when the store updates...
login({ name: 'John', email: 'john@example.com' })
// name and email are reactive refs that update automatically
console.log(name.value) // 'John'
</script>
<template>
<!-- This updates reactively -->
<div>{{ name }}</div>
<button @click="logout">Logout</button>
</template>Understanding Why This Happens
Pinia stores are reactive objects (like reactive()). When you destructure:
const store = useCounterStore()
// store is a reactive Proxy
const { count } = store
// count is now just a primitive number (0), not reactive
// It's like doing: const count = 0
// vs with storeToRefs
const { count } = storeToRefs(store)
// count is now a ref that stays connected to the store
// It's like: const count = computed(() => store.count)Complete Pattern: State, Getters, and Actions
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
// State and getters: USE storeToRefs
const {
items, // state
itemCount, // getter
totalPrice, // getter
isEmpty // getter
} = storeToRefs(cartStore)
// Actions: destructure directly
const {
addItem,
removeItem,
clearCart
} = cartStore
</script>
<template>
<div v-if="isEmpty">Cart is empty</div>
<div v-else>
<p>{{ itemCount }} items - ${{ totalPrice }}</p>
<ul>
<li v-for="item in items" :key="item.id">
{{ item.name }}
<button @click="removeItem(item.id)">Remove</button>
</li>
</ul>
<button @click="clearCart">Clear All</button>
</div>
</template>Alternative: Don't Destructure
If you prefer, you can avoid destructuring entirely:
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// Use userStore.name, userStore.login(), etc. directly
</script>
<template>
<div>{{ userStore.name }}</div>
<button @click="userStore.logout()">Logout</button>
</template>This works fine but is more verbose for stores used frequently in the template.
Common Mistake: Mixing storeToRefs with Actions
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// WRONG: Don't include actions in storeToRefs
// Actions are just functions and storeToRefs will skip them anyway
const { name, login } = storeToRefs(userStore)
// login is undefined! Actions aren't included in storeToRefs result
// CORRECT: Separate state/getters from actions
const { name } = storeToRefs(userStore)
const { login } = userStore
</script>TypeScript Tip
With TypeScript, the types work correctly:
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// name is Ref<string>, email is Ref<string>
const { name, email } = storeToRefs(userStore)
// login is (credentials: Credentials) => Promise<void>
const { login } = userStoreReference
Use URL State for Shareable Filters Instead of Stores
Impact: MEDIUM - Storing ephemeral UI state like filters, search queries, pagination, and sorting only in Pinia or component state means users lose that state on page refresh and cannot share links to specific filtered views. This hurts user experience and SEO.
For state that represents a "view" of data, use URL query parameters instead of or alongside stores.
Task Checklist
- [ ] Identify ephemeral UI state: filters, search, sort, pagination, tab selection
- [ ] Store such state in URL query parameters
- [ ] Sync URL state with component/store state bidirectionally
- [ ] Consider using VueUse's
useRouteQueryfor type-safe URL state - [ ] Keep URL state minimal and human-readable
The Problem: Store-Only State
<script setup>
import { ref } from 'vue'
import { useProductStore } from '@/stores/products'
const productStore = useProductStore()
// Filter state in component/store only
const selectedCategory = ref('all')
const priceRange = ref([0, 1000])
const sortBy = ref('newest')
const searchQuery = ref('')
// Problems with this approach:
// 1. User refreshes page → filters reset to defaults
// 2. User bookmarks page → bookmark doesn't include filter state
// 3. User shares link → friend sees unfiltered view
// 4. Back button doesn't restore previous filter state
</script>The Solution: URL-Based State
<script setup>
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// Read filter state FROM URL
const selectedCategory = computed({
get: () => route.query.category || 'all',
set: (value) => updateQuery({ category: value === 'all' ? undefined : value })
})
const sortBy = computed({
get: () => route.query.sort || 'newest',
set: (value) => updateQuery({ sort: value === 'newest' ? undefined : value })
})
const searchQuery = computed({
get: () => route.query.q || '',
set: (value) => updateQuery({ q: value || undefined })
})
const page = computed({
get: () => parseInt(route.query.page) || 1,
set: (value) => updateQuery({ page: value === 1 ? undefined : value })
})
// Helper to update URL without full navigation
function updateQuery(newParams) {
router.push({
query: {
...route.query,
...newParams
}
})
}
</script>
<template>
<div>
<input v-model="searchQuery" placeholder="Search...">
<select v-model="selectedCategory">
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
<select v-model="sortBy">
<option value="newest">Newest</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
</select>
<!-- URL now looks like: /products?category=electronics&sort=price-low&q=phone -->
<!-- Users can bookmark, share, and refresh without losing state -->
</div>
</template>Using VueUse for Cleaner Code
VueUse provides useRouteQuery for type-safe URL state:
<script setup>
import { useRouteQuery } from '@vueuse/router'
// Automatically syncs with URL query parameters
const category = useRouteQuery('category', 'all')
const sort = useRouteQuery('sort', 'newest')
const search = useRouteQuery('q', '')
const page = useRouteQuery('page', 1, { transform: Number })
const showOutOfStock = useRouteQuery('inStock', false, { transform: Boolean })
// Arrays work too
const selectedTags = useRouteQuery('tags', [], {
transform: (v) => Array.isArray(v) ? v : v ? [v] : []
})
</script>
<template>
<input v-model="search" placeholder="Search...">
<select v-model="category">...</select>
<input type="checkbox" v-model="showOutOfStock"> Show out of stock
</template>Hybrid Approach: URL + Store
For complex state, sync URL with store:
// stores/productFilters.js
import { defineStore } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { watch } from 'vue'
export const useProductFiltersStore = defineStore('productFilters', () => {
const route = useRoute()
const router = useRouter()
// Local reactive state
const category = ref('all')
const sortBy = ref('newest')
const searchQuery = ref('')
const page = ref(1)
// Initialize from URL on store creation
function initFromUrl() {
category.value = route.query.category || 'all'
sortBy.value = route.query.sort || 'newest'
searchQuery.value = route.query.q || ''
page.value = parseInt(route.query.page) || 1
}
// Sync state changes TO URL
function syncToUrl() {
router.replace({
query: {
category: category.value !== 'all' ? category.value : undefined,
sort: sortBy.value !== 'newest' ? sortBy.value : undefined,
q: searchQuery.value || undefined,
page: page.value > 1 ? page.value : undefined
}
})
}
// Watch for URL changes (back/forward navigation)
watch(() => route.query, initFromUrl, { immediate: true })
// Watch for state changes and sync to URL
watch([category, sortBy, searchQuery, page], syncToUrl)
return {
category,
sortBy,
searchQuery,
page,
initFromUrl
}
})What Goes in URL vs Store
| State Type | URL | Store | Notes |
|---|---|---|---|
| Filters | Yes | Optional | Shareable, bookmarkable |
| Search query | Yes | Optional | SEO benefit |
| Pagination | Yes | Optional | Deep linking |
| Sort order | Yes | Optional | User expectation |
| Selected tab | Yes | Optional | Deep linking |
| Modal open state | Maybe | Yes | Usually not shareable |
| Form draft | No | Yes | Private, temporary |
| User session | No | Yes | Security |
| Shopping cart | No | Yes | Persistence needed |
Benefits of URL State
1. Shareable: Users can share exact filtered views 2. Bookmarkable: Save specific searches/filters 3. Browser history: Back/forward works as expected 4. SEO: Search engines can index filtered pages 5. Refresh-safe: State survives page reload 6. Deep linking: Direct links to specific states
Clean URL Best Practices
// GOOD: Clean, readable URLs
/products?category=electronics&sort=price&q=phone
// AVOID: Overly complex URLs
/products?filters=%7B%22category%22%3A%22electronics%22%7D// Use defaults to keep URLs minimal
const sort = useRouteQuery('sort', 'newest')
// URL shows: /products (when using default sort)
// URL shows: /products?sort=price-low (when changed)Reference
Use Pinia for Large-Scale Vue Applications
Impact: MEDIUM - While Vue's Composition API allows creating simple reactive stores with reactive() and ref(), these hand-rolled solutions lack the tooling, conventions, and debugging capabilities needed for large-scale production applications. Pinia is the official Vue state management solution and should be used for any non-trivial application.
Task Checklist
- [ ] Use Pinia for applications with shared state across multiple components
- [ ] Use Pinia when team collaboration requires consistent patterns
- [ ] Use Pinia when you need Vue DevTools state debugging
- [ ] Hand-rolled
reactive()is acceptable only for simple, single-developer apps - [ ] Migrate from Vuex to Pinia (Vuex is in maintenance mode)
When Hand-Rolled State is Acceptable
Simple reactive state is fine for:
- Prototypes and proof-of-concepts
- Very small applications with minimal shared state
- Single-developer projects with limited scope
- Learning purposes
// Simple hand-rolled store - OK for small apps
import { reactive, readonly } from 'vue'
export const store = reactive({
count: 0,
increment() {
this.count++
}
})When to Use Pinia
Use Pinia when you have any of these requirements:
1. DevTools Integration
Pinia provides rich Vue DevTools support:
- Timeline of state changes
- State inspection and editing
- Time-travel debugging
- Action tracking
// Pinia store - fully visible in DevTools
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++ // Tracked in DevTools timeline
}
}
})2. TypeScript Support
Pinia has excellent TypeScript inference:
// Full type inference without extra configuration
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
age: 0,
preferences: {
theme: 'light' as 'light' | 'dark'
}
}),
getters: {
// Return type is inferred
displayName: (state) => state.name || 'Anonymous'
},
actions: {
// Full parameter type checking
setUser(name: string, age: number) {
this.name = name
this.age = age
}
}
})
// Usage is fully typed
const userStore = useUserStore()
userStore.name // string
userStore.displayName // string
userStore.setUser('John', 30) // Type-checked3. Team Collaboration
Pinia enforces conventions that help teams:
// Consistent structure across all stores
// stores/products.js
export const useProductsStore = defineStore('products', {
state: () => ({ /* ... */ }),
getters: { /* ... */ },
actions: { /* ... */ }
})
// stores/cart.js - Same structure
export const useCartStore = defineStore('cart', {
state: () => ({ /* ... */ }),
getters: { /* ... */ },
actions: { /* ... */ }
})4. Hot Module Replacement (HMR)
Pinia supports HMR out of the box - state persists during development:
// State survives code changes during development
// No need to re-login or re-create state after every edit5. Server-Side Rendering (SSR)
Pinia handles SSR state correctly:
// Automatic per-request state isolation
// State serialization for hydration
// No cross-request pollution6. Plugin Ecosystem
Pinia supports plugins for common needs:
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// Now stores can persist to localStorage
export const useSettingsStore = defineStore('settings', {
state: () => ({
theme: 'light',
language: 'en'
}),
persist: true // Automatically saved/restored
})Pinia vs Hand-Rolled Comparison
| Feature | Hand-Rolled reactive() | Pinia |
|---|---|---|
| DevTools integration | No | Yes |
| TypeScript inference | Manual | Automatic |
| HMR support | No | Yes |
| SSR support | Manual | Built-in |
| Plugins | No | Yes |
| Time-travel debugging | No | Yes |
| Learning curve | Lower | Slightly higher |
| Bundle size | Smaller | ~1KB |
| Team conventions | None | Enforced |
Migration from Vuex
Vuex is now in maintenance mode. Migrate to Pinia for new features:
// Vuex (legacy)
export default createStore({
state: { count: 0 },
mutations: {
INCREMENT(state) { state.count++ }
},
actions: {
increment({ commit }) { commit('INCREMENT') }
}
})
// Pinia (recommended)
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() { this.count++ } // No mutations needed!
}
})Pinia advantages over Vuex:
- No mutations (simpler mental model)
- Better TypeScript support
- No nested modules complexity
- Smaller bundle size
- Composition API style available
Pinia Store Styles
Choose the style that fits your team:
Options Style (Similar to Options API)
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2
},
actions: {
increment() { this.count++ }
}
})Setup Style (Composition API)
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
return { count, double, increment }
})Quick Start
npm install pinia// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')Reference
Include Parentheses When Calling Store Methods in Templates
Impact: MEDIUM - When calling methods on a reactive store object in Vue templates, you must include parentheses (even without arguments) to ensure the method is called with the correct this context. Without parentheses, this inside the method may not refer to the store.
This is a subtle gotcha that can cause hard-to-debug issues with hand-rolled reactive stores.
Task Checklist
- [ ] Always use parentheses when calling store methods in templates:
@click="store.increment()" - [ ] Avoid passing store methods as bare references:
@click="store.increment"is problematic - [ ] Consider using arrow functions or Pinia (which handles this automatically)
- [ ] Test store method calls work correctly with
thisreferences
The Problem
// store.js
import { reactive } from 'vue'
export const store = reactive({
count: 0,
increment() {
this.count++ // 'this' should refer to the store
}
})<template>
<!-- WRONG: Without parentheses, 'this' binding may be lost -->
<button @click="store.increment">
{{ store.count }}
</button>
</template>When you write @click="store.increment" (without parentheses), Vue passes the method as a callback to the event handler. The method gets called without being bound to the store object, so this inside increment() may be undefined or the global object instead of the store.
The Solution
<template>
<!-- CORRECT: With parentheses, method is called with correct context -->
<button @click="store.increment()">
{{ store.count }}
</button>
</template>With parentheses, you're telling Vue to call store.increment() directly, which preserves the this context as the store object.
Why This Happens
In JavaScript, when you reference a method without calling it, you get the function itself without its binding:
const store = {
count: 0,
increment() {
this.count++
}
}
// With parentheses - correct context
store.increment() // this === store ✓
// Without parentheses - getting the function
const fn = store.increment
fn() // this === undefined (in strict mode) ✗Vue's event handler behavior:
@click="store.increment"- Vue receivesstore.incrementas a function and calls it later@click="store.increment()"- Vue evaluatesstore.increment()when the event fires
Alternative Solutions
1. Use Arrow Functions in the Template
<template>
<button @click="() => store.increment()">
{{ store.count }}
</button>
</template>2. Bind Methods in the Store Definition
// store.js
import { reactive } from 'vue'
const state = reactive({
count: 0
})
// Methods defined separately, arrow functions don't have 'this' issues
export const store = {
get count() { return state.count },
increment: () => { state.count++ }
}3. Use Pinia (Recommended)
Pinia handles method binding correctly:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++ // Pinia ensures 'this' is correct
}
}
})<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<!-- Both work correctly with Pinia -->
<button @click="counter.increment">Works</button>
<button @click="counter.increment()">Also works</button>
</template>4. Wrap in a Local Function
<script setup>
import { store } from './store'
// Wrap store method to ensure correct binding
function increment() {
store.increment()
}
</script>
<template>
<!-- Now safe to use without parentheses -->
<button @click="increment">
{{ store.count }}
</button>
</template>When This Matters
This gotcha specifically affects:
- Hand-rolled reactive stores using
reactive() - Methods that reference
thisinside them - Direct method references in templates without parentheses
It does NOT affect:
- Pinia stores (methods are auto-bound)
- Arrow function methods (no
thisbinding) - Methods that don't use
this - Method calls with parentheses
Quick Reference
| Pattern | Safe? | Notes |
|---|---|---|
@click="store.method()" | Yes | Explicit call preserves context |
@click="store.method" | No* | Context may be lost |
@click="() => store.method()" | Yes | Arrow wrapper preserves context |
@click="localMethod" | Yes | Component methods are auto-bound |
Pinia: @click="store.action" | Yes | Pinia handles binding |
*Only problematic if the method uses this
Reference
Related skills
FAQ
What causes the no active Pinia error in Vue 3?
The no active Pinia error occurs when useStore() or getActivePinia() runs before app.use(pinia) installs Pinia on the Vue application, commonly during early setup, plugins, or router guards before mount.
How does vue-pinia-best-practices fix Pinia setup?
vue-pinia-best-practices ensures app.use(pinia) is called before app.mount() and audits store access in composables and lifecycle hooks so Pinia is active before any useStore() invocation.
Is Vue Pinia Best Practices safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.