返回 Skills
vuejs-ai/skills· MIT 内容可用

vue-pinia-best-practices

Pinia stores, state management patterns, store setup, and reactivity with stores.

安装

与 skills.sh 相同的 Command / Prompt 安装方式


name: vue-pinia-best-practices description: "Pinia stores, state management patterns, store setup, and reactivity with stores." version: 1.0.0 license: MIT author: github.com/vuejs-ai

Pinia best practices, common gotchas, and state management patterns.

Store Setup

Reactivity

State Patterns

附带文件

reference/pinia-no-active-pinia-error.md
---
title: Fix "No Active Pinia" Error - Store Setup Timing
impact: HIGH
impactDescription: Using Pinia stores before app.use(pinia) causes "getActivePinia was called but there was no active Pinia" error
type: gotcha
tags: [vue3, pinia, state-management, setup, initialization, error]
---

# 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 before `app.mount()`
- [ ] Ensure `app.use(pinia)` is called before `app.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

```javascript
// 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:**

```javascript
// 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

```javascript
// 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:**

```javascript
// 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"

```vue
<!-- 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:**

```vue
<!-- 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>
```

```vue
<!-- 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

```vue
<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:**

```vue
<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

```javascript
// 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:**

```javascript
// 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

```javascript
// 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
- [Vue Land FAQ - No Active Pinia](https://vue-land.github.io/faq/no-active-pinia)
- [Pinia - Using a Store Outside of a Component](https://pinia.vuejs.org/core-concepts/outside-component-usage.html)
- [Pinia - Getting Started](https://pinia.vuejs.org/getting-started.html)
reference/pinia-setup-store-return-all-state.md
---
title: Return All State Properties in Pinia Setup Stores
impact: HIGH
impactDescription: Not returning state properties in setup stores breaks SSR, DevTools, and plugin compatibility
type: gotcha
tags: [vue3, pinia, state-management, setup-stores, ssr, devtools]
---

# 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

```javascript
// 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

```javascript
// 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:

```javascript
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:

```javascript
// 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:

```javascript
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

```javascript
// 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, logout
```

## Reference
- [Pinia - Setup Stores](https://pinia.vuejs.org/core-concepts/#setup-stores)
- [Pinia - SSR](https://pinia.vuejs.org/ssr/)
- [Mastering Pinia - Common Mistakes](https://masteringpinia.com/blog/top-5-mistakes-to-avoid-when-using-pinia)
reference/pinia-store-destructuring-breaks-reactivity.md
---
title: Use storeToRefs When Destructuring Pinia Stores
impact: HIGH
impactDescription: Destructuring Pinia stores directly breaks reactivity - state changes won't trigger UI updates
type: gotcha
tags: [vue3, pinia, state-management, reactivity, destructuring]
---

# 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: `storeToRefs` is for state/getters, direct destructure is for actions

## The Problem: Direct Destructuring

```vue
<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

```vue
<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:

```javascript
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

```vue
<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:

```vue
<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

```vue
<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:

```typescript
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 } = userStore
```

## Reference
- [Pinia - Destructuring from a Store](https://pinia.vuejs.org/core-concepts/#destructuring-from-a-store)
- [Pinia API - storeToRefs](https://pinia.vuejs.org/api/modules/pinia.html#storetorefs)
reference/state-url-for-ephemeral-filters.md
---
title: Use URL State for Shareable Filters Instead of Stores
impact: MEDIUM
impactDescription: Storing filter/search state only in stores loses state on refresh and prevents link sharing
type: best-practice
tags: [vue3, state-management, url, router, filters, ux, vue-router]
---

# 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 `useRouteQuery` for type-safe URL state
- [ ] Keep URL state minimal and human-readable

## The Problem: Store-Only State

```vue
<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

```vue
<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:

```vue
<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:

```javascript
// 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

```javascript
// GOOD: Clean, readable URLs
/products?category=electronics&sort=price&q=phone

// AVOID: Overly complex URLs
/products?filters=%7B%22category%22%3A%22electronics%22%7D
```

```javascript
// 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
- [VueUse - useRouteQuery](https://vueuse.org/router/useRouteQuery/)
- [Vue Router - Query Parameters](https://router.vuejs.org/guide/essentials/passing-props.html#passing-props-to-route-components)
- [Mastering Pinia - URL State](https://masteringpinia.com/blog/top-5-mistakes-to-avoid-when-using-pinia)
reference/state-use-pinia-for-large-apps.md
---
title: Use Pinia for Large-Scale Vue Applications
impact: MEDIUM
impactDescription: Hand-rolled reactive stores lack DevTools integration, TypeScript support, and debugging capabilities needed for production apps
type: best-practice
tags: [vue3, pinia, state-management, devtools, architecture, scalability]
---

# 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

```javascript
// 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

```javascript
// 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:

```typescript
// 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-checked
```

### 3. Team Collaboration

Pinia enforces conventions that help teams:

```javascript
// 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:

```javascript
// State survives code changes during development
// No need to re-login or re-create state after every edit
```

### 5. Server-Side Rendering (SSR)

Pinia handles SSR state correctly:

```javascript
// Automatic per-request state isolation
// State serialization for hydration
// No cross-request pollution
```

### 6. Plugin Ecosystem

Pinia supports plugins for common needs:

```javascript
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:

```javascript
// 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)

```javascript
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    double: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})
```

### Setup Style (Composition API)

```javascript
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

```bash
npm install pinia
```

```javascript
// 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
- [Vue.js - State Management](https://vuejs.org/guide/scaling-up/state-management.html)
- [Pinia Documentation](https://pinia.vuejs.org/)
- [Pinia vs Vuex](https://pinia.vuejs.org/introduction.html#comparison-with-vuex)
reference/store-method-binding-parentheses.md
---
title: Include Parentheses When Calling Store Methods in Templates
impact: MEDIUM
impactDescription: Omitting parentheses on store method calls loses the correct 'this' context, causing unexpected behavior
type: gotcha
tags: [vue3, state-management, reactive, methods, templates, this-binding]
---

# 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 `this` references

## The Problem

```javascript
// store.js
import { reactive } from 'vue'

export const store = reactive({
  count: 0,
  increment() {
    this.count++  // 'this' should refer to the store
  }
})
```

```vue
<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

```vue
<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:

```javascript
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 receives `store.increment` as a function and calls it later
- `@click="store.increment()"` - Vue evaluates `store.increment()` when the event fires

## Alternative Solutions

### 1. Use Arrow Functions in the Template

```vue
<template>
  <button @click="() => store.increment()">
    {{ store.count }}
  </button>
</template>
```

### 2. Bind Methods in the Store Definition

```javascript
// 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:

```javascript
// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++  // Pinia ensures 'this' is correct
    }
  }
})
```

```vue
<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

```vue
<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 `this` inside them
- Direct method references in templates without parentheses

It does NOT affect:
- Pinia stores (methods are auto-bound)
- Arrow function methods (no `this` binding)
- 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
- [Vue.js State Management - Tip on Method Binding](https://vuejs.org/guide/scaling-up/state-management.html#simple-state-management-with-reactivity-api)
- [MDN - this in JavaScript](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/this)