Vue.js: Pinia State Management

Last updated: 2026-10-20

Pinia is the state management library officially recommended for Vue 3 — it replaces Vuex. Pinia offers a more concise API, full TypeScript support, modular hot reloading, and native DevTools integration. Vue now recommends Pinia by default, and Vuex is no longer maintained.

Pinia lets you manage state shared across components (user info, shopping cart, global settings) with more structure than provide/inject and 50% less ceremony than Vuex.

Related Core:computed()

1. What You'll Learn


2. The Nightmare of a Shopping Cart "Inconsistent Across 5 Components"

(1) Pain Point: 5 Components Each Keep Their Own Cart Data

Alice's e-commerce admin has 5 components that need cart data:

JS
// ❌ The broken version: 5 components, 5 copies of the data
// CartIcon.vue
const cartCount = ref(0)

// ProductCard.vue
const localCart = ref([])

// CartPage.vue
const cart = ref({ items: [], total: 0 })

// CheckoutPage.vue
const myCart = ref([])

// Header.vue
const cartItems = ref([])

Product manager Charlie:

"Alice, I add a product in ProductCard and the cart icon doesn't update! The icon says '0' but the page says '1 item'. Five components, five copies of the cart data — they are out of sync!"

(2) The Pinia Solution: One Store Shared by 5 Components

JS
// stores/cart.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    total: 0
  }),
  getters: {
    itemCount: (state) => state.items.length,
    totalPrice: (state) => state.items.reduce((sum, i) => sum + i.price, 0)
  },
  actions: {
    addItem(product) {
      this.items.push(product)
      this.total += product.price
    },
    removeItem(id) {
      this.items = this.items.filter(i => i.id !== id)
    }
  }
})
VUE
<!-- CartIcon.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
// Automatically reactive: when cart.itemCount changes, the icon updates at once
</script>
<template>
  <span>🛒 {{ cart.itemCount }}</span>
</template>
VUE
<!-- ProductCard.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
</script>
<template>
  <button @click="cart.addItem(product)">Add to Cart</button>
</template>

One store, 5 components synced in real time.

(3) Benefits

After adopting Pinia:


3. Pinia vs Vuex: 5 Advantages

Aspect Vuex 4 (Vue 3) Pinia (Vue 3)
Concise API ⭐⭐⭐ Verbose (mutations / actions) ⭐⭐⭐⭐⭐ Concise (state / getters / actions)
TypeScript ⭐⭐⭐ Extra setup required ⭐⭐⭐⭐⭐ Native support
Composition API ⭐⭐ Unfriendly ⭐⭐⭐⭐⭐ First-class
DevTools ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ Improved
Bundle size ~10KB ~1KB

Vue officially recommends Pinia, and Vuex 4 is in maintenance mode (no new features are being added).


4. The Two defineStore Styles

(1) Options Style (Vuex-Like)

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

export const useCounterStore = defineStore('counter', {
  // state: data
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  // getters: derived values (like computed)
  getters: {
    doubleCount: (state) => state.count * 2,
    isZero: (state) => state.count === 0
  },
  
  // actions: methods (like methods)
  actions: {
    increment() {
      this.count++
    },
    async fetchData() {
      const res = await fetch('/api/count')
      this.count = await res.json()
    }
  }
})
JS
// stores/auth.js
import { defineStore } from 'pinia'
const { ref, computed } = Vue

export const useAuthStore = defineStore('auth', () => {
  // 1. state (via ref)
  const user = ref(null)
  const token = ref(localStorage.getItem('token') || '')
  
  // 2. getters (via computed)
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => user.value?.name || 'Guest')
  
  // 3. actions (plain functions)
  function login(credentials) {
    // API call...
    user.value = { name: 'Alice' }
    token.value = 'xxx'
  }
  
  function logout() {
    user.value = null
    token.value = ''
  }
  
  return { user, token, isLoggedIn, userName, login, logout }
})

(3) Options vs Setup

Aspect Options style Setup style
Style state: () => ({}) const x = ref()
getters (state) => ... computed()
actions function() { this.x } Plain functions
TypeScript Manual Inferred automatically
Composability Weaker Strong (other composables can be used)
Recommendation For Vuex veterans For new projects

▶ Example: Simulating defineStore's Options Style with reactive

There is no Pinia CDN dependency available in the browser, so this example hand-writes a store with reactive + provide/inject: state is just object fields, getters are get accessors, and actions are plain methods.

▶ Try it Yourself

Output:

TEXT
The root panel shows "count: 0 | doubleCount: 0 | isZero: true" and the child panel shows "count: 0 | doubleCount: 0".
Clicking the root "+1" twice moves both panels to count: 2 and doubleCount: 4, and isZero flips to false.
Clicking "Child +1" pushes the root to count: 3 and doubleCount: 6; clicking reset zeroes both at once and isZero returns to true.

5. The 5 Core APIs

(1) state: The Data

JS
// Options style
state: () => ({
  count: 0,
  user: null,
  items: []
})

// Setup style
const count = ref(0)
const user = ref(null)
const items = ref([])

(2) getters: Derived Values

JS
// Options style
getters: {
  // a simple getter
  doubleCount: (state) => state.count * 2,
  
  // access another getter (through this)
  ratio(state) {
    return this.doubleCount / 100
  },
  
  // return a function (a parameterized getter)
  getItemById: (state) => (id) => {
    return state.items.find(i => i.id === id)
  }
}

// Setup style
const doubleCount = computed(() => count.value * 2)
const getItemById = (id) => items.value.find(i => i.id === id)

(3) actions: Methods

JS
// Options style
actions: {
  // synchronous
  increment() {
    this.count++
  },
  
  // asynchronous
  async fetchData() {
    const res = await fetch('/api/data')
    this.data = await res.json()
  },
  
  // call other actions
  async loginAndFetch(credentials) {
    await this.login(credentials)
    await this.fetchUser()
  }
}

// Setup style
function increment() {
  count.value++
}
async function fetchData() {
  const res = await fetch('/api/data')
  data.value = await res.json()
}

(4) Using a Store in a Component

VUE
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'

const cart = useCartStore()

// 1. Access state directly (reactive)
console.log(cart.items)

// 2. Destructure with storeToRefs (stays reactive)
const { items, total } = storeToRefs(cart)

// 3. Call an action
cart.addItem(product)

// 4. Watch state changes
watch(() => cart.items, (newItems) => {
  console.log('Cart changed:', newItems)
})
</script>

(5) 5 Things to Watch Out For

JS
// ⚠️ Note 1: destructuring state loses reactivity
const { items } = cart  // ❌ items is a plain value
const { items } = storeToRefs(cart)  // ✅ items is a ref

// ⚠️ Note 2: mutate state through an action
cart.items.push(...)  // ❌ not recommended (direct mutation)
cart.addItem(...)     // ✅ use an action

// ⚠️ Note 3: this inside an action points at the store
actions: {
  increment() {
    this.count++  // ✅ this = store
  }
}

// ⚠️ Note 4: getter caching
getters: {
  doubleCount() {
    console.log('recomputed')  // only logs when a dependency changes
    return this.count * 2
  }
}

// ⚠️ Note 5: outside setup, pass the pinia instance to useStore
import { getActivePinia } from 'pinia'
const cart = useCartStore(getActivePinia())  // inside a .js file

6. Pinia Persistence

(1) Installing the Plugin

BASH
npm install pinia-plugin-persistedstate

(2) Configuring main.js

(3) 5 Persistence Configurations

JS
// stores/cart.js
export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  
  return { items }
}, {
  // 1. Default: the localStorage key is 'cart'
  persist: true,
  
  // 2. Custom key
  persist: {
    key: 'my-cart',
    storage: localStorage
  },
  
  // 3. Persist only part of the state
  persist: {
    paths: ['items']  // only items is saved, total is not
  },
  
  // 4. sessionStorage (cleared when the browser closes)
  persist: {
    storage: sessionStorage
  },
  
  // 5. Custom serialization (encryption, etc.)
  persist: {
    serializer: {
      serialize: (value) => btoa(JSON.stringify(value)),
      deserialize: (value) => JSON.parse(atob(value))
    }
  }
})

▶ Example: Hand-Rolling a Persistent Store with watch + localStorage

This is exactly what pinia-plugin-persistedstate does: read the cache on startup to restore state, then use a deep watch to write state back to localStorage whenever it changes.

▶ Try it Yourself

Output:

TEXT
First open shows "Theme: light | Cart: 0 item(s)", the localStorage line reads (empty), and the child badge is 0.
"Toggle theme" switches the theme to dark; clicking "Add an item" twice makes the cart 2 items and the child badge 2.
The localStorage line updates live to {"theme":"dark","items":[{"id":1766...},{"id":1766...}]}.
Reload the page: the theme is still dark and the cart still holds 2 items (restored from the cache); "Clear cart" empties the items array.

7. Putting It Together: One Store Shared by Many Components

▶ Example: Pinia-Style Global State (Simulated with provide/inject on the CDN)

▶ Expand runnable example (109 lines)
▶ Try it Yourself

Output:

TEXT
Three bordered panels render at once.
Panels 1 and 2 show the same count/double numbers because they inject the same singleton store — clicking +1 in either panel updates both.
Panel 3 tracks its own cart store: the first click on "Add 1 iPhone" shows items: 1, total: $999, the next $1998, and "Clear" resets it to 0 / $0.
TEXT
Panel 1  count: 0 | double: 0     Panel 2  count: 0 | double: 0
Click +1 in panel 2  ->  both panels: count: 1 | double: 2   (one shared store)
Click Reset in panel 1 -> both panels: count: 0 | double: 0
Panel 3  items: 0, total: $0
Add iPhone x2          ->  items: 2, total: $1998
Clear                  ->  items: 0, total: $0
⚠️ This is a teaching demo of Pinia's core idea (a singleton plus provide/inject). Production projects should use the official pinia package (which requires Vite) for DevTools, TypeScript, and modular store splitting.

(1) 📊 The 5 Core APIs at a Glance (Pinia)

API Purpose Example
state Reactive state const count = ref(0)
getters Cached derived values computed(() => count * 2)
actions Methods (may be async) function increment() { count++ }
storeToRefs Destructure while staying reactive const { count } = storeToRefs(cart)
Persistence localStorage sync pinia-plugin-persistedstate

Output:

TEXT
A five-row reference table mapping each core API to its purpose and a one-line example — the fastest way to recall the surface area of a store.
TEXT
Renders one table: 5 rows x 3 columns (API | Purpose | Example).

(2) 📊 5 Common Mistakes at a Glance

Mistake Symptom Fix
Destructuring state Data never updates Use storeToRefs
Mutating state directly Warning Move it into an action
Forgetting await on an async action Result not ready await cart.fetchData()
pinia not registered useStore is not a function app.use(pinia) in main.js
A field is not persisted Wrong key Check the paths config

Output:

TEXT
A five-row troubleshooting table covering the Pinia errors you are most likely to hit, from lost reactivity to missing plugin registration, each with its one-line fix.
TEXT
Renders one table: 5 rows x 3 columns (Mistake | Symptom | Fix).

(3) 📊 5 Real-World Scenarios

Scenario Store Key fields
Authentication useAuthStore user, token, isLoggedIn
Shopping cart useCartStore items, total, itemCount
Theme useThemeStore theme, locale, density
Notifications useNotificationStore notifications, unread
Data useDataStore list, loading, error

Output:

TEXT
A five-row mapping table from business domain to store name and its key fields — a ready-made skeleton for splitting an app's global state into modular stores.
TEXT
Renders one table: 5 rows x 3 columns (Scenario | Store | Key fields).

Related Core:computed()

❓ FAQ

Q Pinia or Vuex?
A Pinia. Vue officially recommends it and Vuex 4 is unmaintained. New projects must use Pinia; older Vuex projects can migrate gradually.
Q Does Pinia replace provide/inject?
A No. Pinia is global state management, while provide/inject is cross-level communication. Pinia handles "application-wide state" (user, cart); provide/inject handles "theme/i18n/config".
Q When should I use Pinia vs a composable?
A Pinia is for global state shared across components (user, cart). A composable is for component-level reusable logic (useMouse, useFetch). Pinia is more structured and comes with DevTools.
Q How do I keep Pinia modular?
A One file per store, all under src/stores/. Pinia supports tree-shaking automatically.
Q When do I need storeToRefs?
A Whenever you destructure state; otherwise reactivity is lost. Getters and actions do not need it.
Q Which storage backends does Pinia persistence support?
A localStorage by default. It also supports sessionStorage, custom storage (IndexedDB, cookies, etc.), and custom serialization (encryption).
Q How does Pinia perform?
A State mutations trigger reactive updates, getters are cached automatically, and DevTools is optimized. It is roughly 30-50% faster than Vuex because there is no mutations layer.
Q Can Pinia be used with SSR?
A Yes. import { createPinia } from 'pinia' and create a fresh instance per request. Nuxt 3 has Pinia integration built in.

📖 Summary


📝 Exercises

  1. Basic (difficulty ⭐) Build a simple counter store:

    • count state
    • doubleCount getter
    • increment / decrement / reset actions
    • Share it across 3 components
  2. Intermediate (difficulty ⭐⭐) Build a shopping cart store:

    • items array
    • itemCount / totalPrice getters
    • addItem / removeItem / clearCart actions
    • Persist to localStorage
    • Test it in 5 components (ProductCard / CartIcon / CartPage / Checkout / Header)
  3. Challenge (difficulty ⭐⭐⭐) Build a complete e-commerce admin store system:

    1. 5 stores: auth / cart / theme / notification / product
    2. Full state + getters + actions for each store
    3. Persistence (auth/cart/theme)
    4. Setup style with TypeScript
    5. storeToRefs destructuring
    6. Cross-store calls (cart asks auth whether the user is logged in)
    7. Unit tests (with Vitest)
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏