Vue.js state management

What is state management?

State management refers to the way data is managed and shared within an application. When applications become complex and components need to share state, a centralized state management solution is needed.

Why is state management needed?

Problem Scenario

组件 A
  ├── 组件 B
  │   └── 组件 D
  └── 组件 C
      └── 组件 E

If component D and component E need to share data, passing props and events can be tedious.

Vue 3 state management solution

1. Simple state management (Reactive)

For simple applications, you can usereactiveCreate global state:

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

export const store = reactive({
  count: 0,
  increment() {
    this.count++
  }
})
<!-- ComponentA.vue -->
<script setup>
import { store } from './store'
</script>

<template>
  <div>
    <p>Count: {{ store.count }}</p>
    <button @click="store.increment()">增加</button>
  </div>
</template>

Pinia is the officially recommended state management library for Vue 3. It is simpler than Vuex and more in line with the design concept of Vue 3.

Install Pinia

npm install pinia

Create Store

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

export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')
  
  // getters
  const doubleCount = computed(() => count.value * 2)
  
  // actions
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 1000))
    count.value++
  }
  
  return {
    count,
    name,
    doubleCount,
    increment,
    decrement,
    incrementAsync
  }
})

Use in application

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)

app.use(pinia)
app.mount('#app')

Used in components

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

const counter = useCounterStore()
</script>

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    
    <button @click="counter.increment()">增加</button>
    <button @click="counter.decrement()">减少</button>
    <button @click="counter.incrementAsync()">异步增加</button>
  </div>
</template>

Practical example: Shopping Cart Store

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

export const useCartStore = defineStore('cart', () => {
  // State
  const items = ref([])
  
  // Getters
  const totalItems = computed(() => {
    return items.value.reduce((sum, item) => sum + item.quantity, 0)
  })
  
  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => {
      return sum + item.price * item.quantity
    }, 0)
  })
  
  const isEmpty = computed(() => items.value.length === 0)
  
  // Actions
  function addItem(product) {
    const existingItem = items.value.find(item => item.id === product.id)
    
    if (existingItem) {
      existingItem.quantity++
    } else {
      items.value.push({
        ...product,
        quantity: 1
      })
    }
  }
  
  function removeItem(productId) {
    const index = items.value.findIndex(item => item.id === productId)
    if (index > -1) {
      items.value.splice(index, 1)
    }
  }
  
  function updateQuantity(productId, quantity) {
    const item = items.value.find(item => item.id === productId)
    if (item) {
      item.quantity = Math.max(1, quantity)
    }
  }
  
  function clearCart() {
    items.value = []
  }
  
  async function checkout() {
    // 模拟 API 调用
    await new Promise(resolve => setTimeout(resolve, 1000))
    
    const order = {
      items: items.value,
      total: totalPrice.value,
      timestamp: new Date()
    }
    
    clearCart()
    return order
  }
  
  return {
    items,
    totalItems,
    totalPrice,
    isEmpty,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    checkout
  }
})

Using shopping cart Store

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

const cart = useCartStore()

const products = [
  { id: 1, name: 'iPhone 14', price: 5999 },
  { id: 2, name: 'MacBook Pro', price: 12999 },
  { id: 3, name: 'iPad Air', price: 4399 },
  { id: 4, name: 'AirPods Pro', price: 1999 }
]

async function handleCheckout() {
  try {
    const order = await cart.checkout()
    alert(`订单已提交!总金额:¥${order.total}`)
  } catch (error) {
    alert('结算失败,请重试')
  }
}
</script>

<template>
  <div class="shopping-app">
    <div class="products">
      <h2>商品列表</h2>
      <div v-for="product in products" :key="product.id" class="product-card">
        <h3>{{ product.name }}</h3>
        <p class="price">¥{{ product.price }}</p>
        <button @click="cart.addItem(product)">加入购物车</button>
      </div>
    </div>
    
    <div class="cart">
      <h2>购物车 ({{ cart.totalItems }})</h2>
      
      <div v-if="cart.isEmpty" class="empty">
        购物车是空的
      </div>
      
      <div v-else>
        <div v-for="item in cart.items" :key="item.id" class="cart-item">
          <div class="item-info">
            <h4>{{ item.name }}</h4>
            <p>¥{{ item.price }}</p>
          </div>
          <div class="item-actions">
            <input 
              type="number" 
              :value="item.quantity"
              @input="cart.updateQuantity(item.id, Number($event.target.value))"
              min="1"
            />
            <button @click="cart.removeItem(item.id)" class="remove-btn">
              删除
            </button>
          </div>
        </div>
        
        <div class="cart-footer">
          <div class="total">
            <strong>总计:¥{{ cart.totalPrice }}</strong>
          </div>
          <button @click="handleCheckout" class="checkout-btn">
            结算
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.shopping-app {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 20px;
  padding: 20px;
  max-width: 1200px;
  margin: 0 auto;
}

.products {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 15px;
  align-content: start;
}

.product-card {
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  text-align: center;
}

.price {
  font-size: 20px;
  font-weight: bold;
  color: #42b983;
  margin: 10px 0;
}

button {
  padding: 8px 16px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:hover {
  background-color: #35a372;
}

.cart {
  position: sticky;
  top: 20px;
  height: fit-content;
  padding: 20px;
  border: 1px solid #ddd;
  border-radius: 8px;
  background-color: #f9f9f9;
}

.empty {
  text-align: center;
  color: #999;
  padding: 40px 0;
}

.cart-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 15px 0;
  border-bottom: 1px solid #ddd;
}

.item-actions {
  display: flex;
  gap: 10px;
  align-items: center;
}

.item-actions input {
  width: 60px;
  padding: 5px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.remove-btn {
  background-color: #f56c6c;
}

.cart-footer {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 2px solid #ddd;
}

.total {
  font-size: 18px;
  margin-bottom: 15px;
  text-align: right;
}

.checkout-btn {
  width: 100%;
  padding: 12px;
  font-size: 16px;
}
</style>

Practical example: User authentication Store

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

export const useAuthStore = defineStore('auth', () => {
  // State
  const user = ref(null)
  const token = ref(localStorage.getItem('token') || null)
  const loading = ref(false)
  
  // Getters
  const isAuthenticated = computed(() => !!token.value)
  const isAdmin = computed(() => user.value?.role === 'admin')
  
  // Actions
  async function login(credentials) {
    loading.value = true
    try {
      // 模拟 API 调用
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      // 模拟响应
      const response = {
        user: {
          id: 1,
          name: credentials.username,
          email: `${credentials.username}@example.com`,
          role: credentials.username === 'admin' ? 'admin' : 'user'
        },
        token: 'fake-jwt-token-' + Date.now()
      }
      
      user.value = response.user
      token.value = response.token
      localStorage.setItem('token', response.token)
      
      return response
    } catch (error) {
      throw new Error('登录失败')
    } finally {
      loading.value = false
    }
  }
  
  function logout() {
    user.value = null
    token.value = null
    localStorage.removeItem('token')
  }
  
  async function fetchUser() {
    if (!token.value) return
    
    loading.value = true
    try {
      // 模拟 API 调用
      await new Promise(resolve => setTimeout(resolve, 500))
      
      user.value = {
        id: 1,
        name: '张三',
        email: 'zhangsan@example.com',
        role: 'user'
      }
    } catch (error) {
      logout()
    } finally {
      loading.value = false
    }
  }
  
  async function updateProfile(data) {
    loading.value = true
    try {
      await new Promise(resolve => setTimeout(resolve, 1000))
      user.value = { ...user.value, ...data }
    } finally {
      loading.value = false
    }
  }
  
  return {
    user,
    token,
    loading,
    isAuthenticated,
    isAdmin,
    login,
    logout,
    fetchUser,
    updateProfile
  }
})

Use Authentication Store

<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()

const loginForm = ref({
  username: '',
  password: ''
})

async function handleLogin() {
  try {
    await auth.login(loginForm.value)
    alert('登录成功!')
  } catch (error) {
    alert(error.message)
  }
}

function handleLogout() {
  auth.logout()
  alert('已退出登录')
}
</script>

<template>
  <div class="auth-demo">
    <div v-if="!auth.isAuthenticated" class="login-form">
      <h2>登录</h2>
      <form @submit.prevent="handleLogin">
        <input 
          v-model="loginForm.username" 
          placeholder="用户名"
          required
        />
        <input 
          v-model="loginForm.password" 
          type="password"
          placeholder="密码"
          required
        />
        <button type="submit" :disabled="auth.loading">
          {{ auth.loading ? '登录中...' : '登录' }}
        </button>
      </form>
      <p class="hint">提示:输入 "admin" 作为用户名可获得管理员权限</p>
    </div>
    
    <div v-else class="user-profile">
      <h2>欢迎,{{ auth.user.name }}!</h2>
      <div class="user-info">
        <p><strong>邮箱:</strong>{{ auth.user.email }}</p>
        <p><strong>角色:</strong>{{ auth.user.role }}</p>
        <span v-if="auth.isAdmin" class="badge">管理员</span>
      </div>
      
      <div v-if="auth.isAdmin" class="admin-panel">
        <h3>🔧 管理员面板</h3>
        <p>只有管理员可以看到这个面板</p>
      </div>
      
      <button @click="handleLogout" class="logout-btn">退出登录</button>
    </div>
  </div>
</template>

<style scoped>
.auth-demo {
  max-width: 500px;
  margin: 20px auto;
  padding: 30px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.login-form input {
  width: 100%;
  padding: 10px;
  margin-bottom: 15px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.login-form button {
  width: 100%;
  padding: 12px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}

.login-form button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.hint {
  margin-top: 15px;
  font-size: 14px;
  color: #666;
  text-align: center;
}

.user-info {
  padding: 20px;
  background-color: #f9f9f9;
  border-radius: 8px;
  margin: 20px 0;
}

.badge {
  display: inline-block;
  padding: 4px 12px;
  background-color: #42b983;
  color: white;
  border-radius: 12px;
  font-size: 12px;
}

.admin-panel {
  padding: 20px;
  background-color: #fff3cd;
  border: 1px solid #ffc107;
  border-radius: 8px;
  margin: 20px 0;
}

.logout-btn {
  width: 100%;
  padding: 12px;
  background-color: #f56c6c;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 16px;
}
</style>

Combinations between Stores

// stores/user.js
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'

export const useUserStore = defineStore('user', () => {
  const auth = useAuthStore()
  
  async function updateSettings(settings) {
    if (!auth.isAuthenticated) {
      throw new Error('请先登录')
    }
    
    // 更新设置逻辑
  }
  
  return {
    updateSettings
  }
})

Pinia plugin

// plugins/piniaLogger.js
export function piniaLogger(context) {
  context.store.$subscribe((mutation, state) => {
    console.log(`[${context.store.$id}] ${mutation.type}`, state)
  })
}

// main.js
import { createPinia } from 'pinia'
import { piniaLogger } from './plugins/piniaLogger'

const pinia = createPinia()
pinia.use(piniaLogger)

Persistent storage

// stores/settings.js
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'

export const useSettingsStore = defineStore('settings', () => {
  const theme = ref(localStorage.getItem('theme') || 'light')
  const language = ref(localStorage.getItem('language') || 'zh-CN')
  
  // 监听变化并保存到 localStorage
  watch(theme, (newTheme) => {
    localStorage.setItem('theme', newTheme)
  })
  
  watch(language, (newLang) => {
    localStorage.setItem('language', newLang)
  })
  
  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }
  
  return {
    theme,
    language,
    toggleTheme
  }
})

Summarize

  • Simple Application: UsereactiveCreate global state
  • Complex Applications: Use Pinia for state management
  • Pinia Advantages:
    • Simpler API
    • Full TypeScript support
    • Better development tool support
    • Modular design
    • Support plug-in system
  • Best Practices:
    • Store divided by functional modules
    • Use composed API style
    • Proper use of getters to cache calculation results
    • Asynchronous operations are placed in actions

Next step

Next, learn Animations and Transitions to learn how to add animation effects to your application.