vue pinia state management store

[Vue] Pinia

為什麼需要狀態管理?

幾個彼此沒有父子關係的元件要共享同一份資料時,麻煩就來了。看個例子:

App
├── Header          ← 需要 user
├── Sidebar         ← 需要 user
└── Main
    └── Settings    ← 需要修改 user

這三個元件不在同一條父子鏈上。用 props 得一層層往下傳;用 provide/inject 也得在每個地方分別注入,要改的時候牽連一大片。專案越大,這個成本越高。

狀態管理的思路是:把跨元件共享的資料集中放在一個地方。哪個元件要用就直接拿、要改就直接改,改完所有用到的地方自動更新。


Pinia 是什麼?

Pinia 是 Vue 官方的狀態管理套件,Vuex 的後繼者。名稱源自西班牙文「鳳梨」(piña)。

npm install pinia

掛載到 app

// 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')

建立 Store

Store 就是集中管理狀態的單位。Pinia 有兩種定義寫法。

Option Store(選項式)

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

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

Setup Store(組合式)— 推薦

寫起來跟 Composition API 一模一樣:

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state → ref
  const count = ref(0)
  const name = ref('Jeremy')

  // getters → computed
  const doubleCount = computed(() => count.value * 2)

  // actions → function
  function increment() {
    count.value++
  }

  return { count, name, doubleCount, increment }
})

兩種寫法效果相同。Setup Store 的對應關係很好記:ref 就是 state、computed 就是 getter、function 就是 action。


State — 狀態

State 是 store 裡存的資料。Setup Store 裡就是 ref,Option Store 則是 state() 回傳的東西。

在元件裡使用

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <p>{{ counter.count }}</p>
  <p>{{ counter.name }}</p>
</template>

直接修改 state

const counter = useCounterStore()

// 直接改
counter.count++

// 用 $patch 一次改多個
counter.$patch({
  count: counter.count + 1,
  name: 'Aira'
})

// $patch 也可以傳函式(適合修改陣列)
counter.$patch((state) => {
  state.items.push({ id: 1, name: 'item' })
  state.count++
})

解構要用 storeToRefs

import { storeToRefs } from 'pinia'

const counter = useCounterStore()

// ❌ 直接解構會失去響應式!
const { count, name } = counter

// ✅ 用 storeToRefs
const { count, name } = storeToRefs(counter)

// 注意:actions(函式)直接解構就好,不需要 storeToRefs
const { increment } = counter

為什麼?counter.count 本質上是個 ref,直接解構拿到的只是當下的原始值,不是 ref 本身。storeToRefs 會幫你保住 ref 的連結。


Getters — 計算屬性

Getter 就是 store 版的 computed:從 state 算出衍生資料,一樣有快取。

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  // getter:算出總金額
  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  // getter:算出商品數量
  const itemCount = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  return { items, totalPrice, itemCount }
})

元件中使用:

<template>
  <p>購物車有 {{ cart.itemCount }} 件商品</p>
  <p>總計 ${{ cart.totalPrice }}</p>
</template>

Getter 存取其他 store 的 state

import { useAuthStore } from './auth'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  const discountedTotal = computed(() => {
    const auth = useAuthStore()
    const total = items.value.reduce((sum, item) => sum + item.price, 0)
    // VIP 打 8 折
    return auth.isVip ? total * 0.8 : total
  })

  return { items, discountedTotal }
})

Actions — 操作

Action 就是修改 state 的函式。跟 Vuex 最大的不同:Pinia 沒有 mutation,想改 state 就直接在函式裡改。

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref('')
  const isLoggedIn = computed(() => !!token.value)

  async function login(email, password) {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password })
    })
    const data = await res.json()

    user.value = data.user
    token.value = data.token
    localStorage.setItem('token', data.token)
  }

  function logout() {
    user.value = null
    token.value = ''
    localStorage.removeItem('token')
  }

  return { user, token, isLoggedIn, login, logout }
})

同步、非同步(async/await)都可以,沒有額外限制。


跟 Vuex 的差別

VuexPinia
Mutation需要,同步修改 state 一定要用沒有,直接改
Action處理非同步,再 commit mutation直接改 state,同步非同步都行
Module需要,而且有 namespace 的坑每個 store 獨立,不需要 module
TypeScript支援差,很多地方要手動標型別原生支援,推導很好
體積~10kb~1.5kb
DevTools支援支援

Vuex 最大的痛點就是 mutation。你想改一個 state,得先寫一個 mutation,然後在 action 裡 commit 這個 mutation。一個簡單的修改要經過三個步驟:

// Vuex:改一個 count 要三步
// 1. 定義 mutation
mutations: { INCREMENT(state) { state.count++ } }
// 2. 定義 action
actions: { increment({ commit }) { commit('INCREMENT') } }
// 3. 元件裡呼叫
store.dispatch('increment')
// Pinia:一步搞定
counter.count++
// 或者
counter.increment()

Vuex 的 mutation 設計初衷是為了讓 DevTools 能追蹤每次修改。但 Pinia 在沒有 mutation 的情況下一樣做到了。所以 mutation 就變成了純粹的 boilerplate(樣板程式碼)。

結論:新專案直接用 Pinia,不要再用 Vuex 了。 Vue 官方也這麼建議。


實際使用情境

情境 1:使用者認證

// stores/auth.js
export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref(localStorage.getItem('token') || '')

  const isLoggedIn = computed(() => !!token.value)
  const isAdmin = computed(() => user.value?.role === 'admin')

  async function login(credentials) {
    const res = await api.post('/login', credentials)
    token.value = res.data.token
    user.value = res.data.user
    localStorage.setItem('token', res.data.token)
  }

  function logout() {
    token.value = ''
    user.value = null
    localStorage.removeItem('token')
  }

  async function fetchUser() {
    if (!token.value) return
    const res = await api.get('/me')
    user.value = res.data
  }

  return { user, token, isLoggedIn, isAdmin, login, logout, fetchUser }
})

情境 2:購物車

// stores/cart.js
export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  const totalPrice = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  const totalItems = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

  function addItem(product) {
    const existing = items.value.find(item => item.id === product.id)
    if (existing) {
      existing.quantity++
    } else {
      items.value.push({ ...product, quantity: 1 })
    }
  }

  function removeItem(productId) {
    items.value = items.value.filter(item => item.id !== productId)
  }

  function clearCart() {
    items.value = []
  }

  return { items, totalPrice, totalItems, addItem, removeItem, clearCart }
})

情境 3:主題切換

// stores/theme.js
export const useThemeStore = defineStore('theme', () => {
  const isDark = ref(localStorage.getItem('theme') === 'dark')

  const theme = computed(() => isDark.value ? 'dark' : 'light')

  function toggle() {
    isDark.value = !isDark.value
    localStorage.setItem('theme', theme.value)
    document.documentElement.classList.toggle('dark', isDark.value)
  }

  return { isDark, theme, toggle }
})

什麼時候該用 Pinia?

不是所有狀態都該丟進 Pinia。可以這樣判斷:

適合放進 Pinia 的:

  • 多個不相關的元件都會用到的資料(使用者資訊、購物車、主題)
  • 頁面切換後還要保留的狀態
  • 業務邏輯複雜、需要集中管理的狀態

不需要放進 Pinia 的:

  • 只有一個元件自己用的狀態 → 用元件的 ref
  • 只有父子之間傳的資料 → 用 props + emit
  • 只有某棵子樹要用的資料 → 用 provide / inject
  • 表單的暫時狀態 → 用元件的 refreactive

其實就是一個原則:能用簡單的方式解決就用簡單的方式,不要動不動就上 Pinia。先用 props,不夠再用 provide/inject,真的需要全域共享再用 Pinia。


小結

概念一句話
Store集中管理狀態的倉庫
State就是 ref,存放資料
Getter就是 computed,衍生資料
Action就是函式,修改資料的操作
storeToRefs解構 store 時保留響應式

Pinia 真的沒有想像中那麼可怕。如果你已經會用 Composition API 的 refcomputed、普通函式,那你其實已經會用 Pinia 了 — 它們用的是完全一樣的概念,只是多了一層「集中管理」的外殼而已。

Latest Updates

  • 2026.06.11 Content updated