7.7: State Management with Pinia
Learn how to fetch data from APIs and handle asynchronous operations in Vue applications. Master HTTP requests, loading states, error handling, and integrate external data sources into your Vue projects.
1. Introduction to State Management
Why State Management?
Problem without state management:
<!-- Component A -->
<script setup>
import { ref } from 'vue'
const user = ref({ name: 'Alice', role: 'admin' })
// How to share user with Component B?
</script>
Solutions:
- Props - Only works for parent-child (not siblings)
- Events - Only works upward (child to parent)
- Provide/Inject - Better, but limited features
- Global State Management - Best for complex apps
When to use Pinia:
- Multiple components need same data
- Data needs to persist across routes
- Complex data transformations needed
- You need time-travel debugging
- App has authentication state
- Shopping cart, user preferences, etc.
Pinia vs Vuex
| Feature | Pinia | Vuex |
|---|---|---|
| API | Simpler, intuitive | More boilerplate |
| TypeScript | Excellent | Good |
| DevTools | Excellent | Good |
| Composition API | Native support | Requires adapter |
| Bundle Size | Smaller | Larger |
| Official Status | Official (recommended) | Legacy |
Pinia is the official state management library for Vue 3.
2. Setting Up Pinia
Installation
npm install pinia
Basic Setup
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
3. Creating Your First Store
Basic Store Structure
// stores/counter.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
const name = ref('Counter')
// Getters (computed)
const doubleCount = computed(() => count.value * 2)
const isEven = computed(() => count.value % 2 === 0)
// Actions (methods)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function incrementBy(amount) {
count.value += amount
}
function reset() {
count.value = 0
}
// Return everything
return {
// State
count,
name,
// Getters
doubleCount,
isEven,
// Actions
increment,
decrement,
incrementBy,
reset
}
})
Using the Store
<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>
<p>Is Even: {{ counter.isEven }}</p>
<button @click="counter.increment">+</button>
<button @click="counter.decrement">-</button>
<button @click="counter.incrementBy(5)">+5</button>
<button @click="counter.reset">Reset</button>
</div>
</template>
Destructuring State
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// Destructure state and getters (keeps reactivity)
const { count, doubleCount, isEven } = storeToRefs(counter)
// Destructure actions (don't need storeToRefs)
const { increment, decrement, reset } = counter
</script>
<template>
<div>
<p>{{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
Important: Always use storeToRefs() when destructuring state/getters to maintain reactivity!
4. User Authentication Store
Complete Auth Store Example
// stores/auth.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('token') || null)
const isLoading = ref(false)
const error = ref(null)
// Getters
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const userName = computed(() => user.value?.name || 'Guest')
// Actions
async function login(email, password) {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
})
if (!response.ok) {
throw new Error('Invalid credentials')
}
const data = await response.json()
token.value = data.token
user.value = data.user
// Persist token
localStorage.setItem('token', data.token)
return true
} catch (err) {
error.value = err.message
return false
} finally {
isLoading.value = false
}
}
async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` }
})
} catch (err) {
console.error('Logout error:', err)
} finally {
// Clear state
user.value = null
token.value = null
localStorage.removeItem('token')
}
}
async function fetchUser() {
if (!token.value) return
try {
const response = await fetch('/api/auth/user', {
headers: { Authorization: `Bearer ${token.value}` }
})
if (response.ok) {
user.value = await response.json()
} else {
// Invalid token, logout
await logout()
}
} catch (err) {
console.error('Fetch user error:', err)
}
}
async function register(email, password, name) {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name })
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.message || 'Registration failed')
}
const data = await response.json()
token.value = data.token
user.value = data.user
localStorage.setItem('token', data.token)
return true
} catch (err) {
error.value = err.message
return false
} finally {
isLoading.value = false
}
}
return {
// State
user,
token,
isLoading,
error,
// Getters
isAuthenticated,
isAdmin,
userName,
// Actions
login,
logout,
fetchUser,
register
}
})
Using Auth Store
<!-- views/Login.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const email = ref('')
const password = ref('')
async function handleLogin() {
const success = await auth.login(email.value, password.value)
if (success) {
router.push('/dashboard')
}
}
</script>
<template>
<form @submit.prevent="handleLogin">
<h2>Login</h2>
<div v-if="auth.error" class="error">
{{ auth.error }}
</div>
<input
v-model="email"
type="email"
placeholder="Email"
required
>
<input
v-model="password"
type="password"
placeholder="Password"
required
>
<button type="submit" :disabled="auth.isLoading">
{{ auth.isLoading ? 'Loading...' : 'Login' }}
</button>
</form>
</template>
<!-- components/Header.vue -->
<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
<template>
<header>
<nav>
<RouterLink to="/">Home</RouterLink>
<div v-if="auth.isAuthenticated">
<span>Welcome, {{ auth.userName }}!</span>
<RouterLink to="/dashboard">Dashboard</RouterLink>
<button @click="auth.logout">Logout</button>
</div>
<div v-else>
<RouterLink to="/login">Login</RouterLink>
<RouterLink to="/register">Register</RouterLink>
</div>
</nav>
</header>
</template>
5. Shopping Cart Store
// stores/cart.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
// State
const items = ref([])
// Getters
const itemCount = 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({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
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) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
function incrementQuantity(productId) {
const item = items.value.find(item => item.id === productId)
if (item) {
item.quantity++
}
}
function decrementQuantity(productId) {
const item = items.value.find(item => item.id === productId)
if (item) {
if (item.quantity > 1) {
item.quantity--
} else {
removeItem(productId)
}
}
}
function clearCart() {
items.value = []
}
return {
// State
items,
// Getters
itemCount,
totalPrice,
isEmpty,
// Actions
addItem,
removeItem,
updateQuantity,
incrementQuantity,
decrementQuantity,
clearCart
}
})
Usage:
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { items, itemCount, totalPrice } = storeToRefs(cart)
</script>
<template>
<div class="cart">
<h2>Shopping Cart ({{ itemCount }})</h2>
<div v-if="cart.isEmpty">
Your cart is empty
</div>
<div v-else>
<div v-for="item in items" :key="item.id" class="cart-item">
<img :src="item.image" :alt="item.name">
<div>
<h3>{{ item.name }}</h3>
<p>${{ item.price }}</p>
</div>
<div class="quantity-controls">
<button @click="cart.decrementQuantity(item.id)">-</button>
<span>{{ item.quantity }}</span>
<button @click="cart.incrementQuantity(item.id)">+</button>
</div>
<button @click="cart.removeItem(item.id)">Remove</button>
</div>
<div class="cart-total">
<h3>Total: ${{ totalPrice.toFixed(2) }}</h3>
<button @click="cart.clearCart">Clear Cart</button>
</div>
</div>
</div>
</template>
6. Persisting State
LocalStorage Plugin
// plugins/piniaLocalStorage.js
export function piniaLocalStorage(context) {
const { store } = context
// Load initial state from localStorage
const stored = localStorage.getItem(store.$id)
if (stored) {
store.$patch(JSON.parse(stored))
}
// Subscribe to changes and save to localStorage
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
Register plugin:
// main.js
import { createPinia } from 'pinia'
import { piniaLocalStorage } from './plugins/piniaLocalStorage'
const pinia = createPinia()
pinia.use(piniaLocalStorage)
app.use(pinia)
Selective Persistence
// stores/preferences.js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const usePreferencesStore = defineStore('preferences', () => {
const theme = ref(localStorage.getItem('theme') || 'light')
const language = ref(localStorage.getItem('language') || 'en')
const notifications = ref(
JSON.parse(localStorage.getItem('notifications') || 'true')
)
function setTheme(newTheme) {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
document.body.className = newTheme
}
function setLanguage(newLanguage) {
language.value = newLanguage
localStorage.setItem('language', newLanguage)
}
function toggleNotifications() {
notifications.value = !notifications.value
localStorage.setItem('notifications', JSON.stringify(notifications.value))
}
return {
theme,
language,
notifications,
setTheme,
setLanguage,
toggleNotifications
}
})
7. Multiple Stores Composition
Store Using Another Store
// stores/todos.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
export const useTodosStore = defineStore('todos', () => {
const auth = useAuthStore()
const todos = ref([])
const isLoading = ref(false)
const userTodos = computed(() => {
if (!auth.user) return []
return todos.value.filter(todo => todo.userId === auth.user.id)
})
async function fetchTodos() {
if (!auth.isAuthenticated) return
isLoading.value = true
try {
const response = await fetch('/api/todos', {
headers: { Authorization: `Bearer ${auth.token}` }
})
todos.value = await response.json()
} finally {
isLoading.value = false
}
}
async function addTodo(text) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${auth.token}`
},
body: JSON.stringify({ text })
})
const newTodo = await response.json()
todos.value.push(newTodo)
}
return {
todos,
isLoading,
userTodos,
fetchTodos,
addTodo
}
})
8. DevTools Integration
Debugging with Vue DevTools
Features:
- Timeline: See all state mutations
- Inspector: View current store state
- Time Travel: Revert to previous states
- Mutation Details: See what changed
Store naming for DevTools:
// Give stores descriptive IDs
export const useUserStore = defineStore('user', () => { /* ... */ })
export const useCartStore = defineStore('cart', () => { /* ... */ })
export const useProductsStore = defineStore('products', () => { /* ... */ })
9. Practical Exercises
Exercise 5.7.1: User Preferences Store
Create a preferences store:
- Theme (light/dark)
- Language selection
- Font size
- Persist to localStorage
- Apply preferences on load
Exercise 5.7.2: Products and Cart
Build e-commerce stores:
- Products store (fetch from API)
- Cart store (add/remove/update)
- Favorites store (wishlist)
- Sync cart across tabs (localStorage events)
Exercise 5.7.3: Notifications System
Create notification store:
- Add notifications (info/success/warning/error)
- Auto-dismiss after timeout
- Maximum number of notifications
- Pause on hover
Exercise 5.7.4: Multi-User Todo App
Build collaborative todos:
- Auth store (login/logout)
- Todos store (CRUD operations)
- Filter by user
- Real-time updates (optional: websockets)
10. Knowledge Check
Question 1: When should you use Pinia vs props/events?
Show answer
Use props/events for parent-child communication. Use Pinia when multiple unrelated components need to access/modify the same data, or when data needs to persist across routes.Question 2: Why use storeToRefs() when destructuring?
Show answer
`storeToRefs()` maintains reactivity when destructuring state and getters. Without it, destructured values lose reactivity. Actions don't need it because they're just functions.Question 3: What's the difference between state and getters?
Show answer
State is raw data storage. Getters are computed properties derived from state - they're cached and only recalculate when dependencies change.Question 4: How do you handle async operations in stores?
Show answer
Create async actions that update state. Typically track loading/error states, make API calls, and update state based on results.11. Key Takeaways
- Pinia is the official state management for Vue 3
- Stores contain state, getters, and actions
- State is defined with
ref()orreactive() - Getters are
computed()properties - Actions are regular functions (can be async)
storeToRefs()maintains reactivity when destructuring- Stores can use other stores for composition
- LocalStorage can persist state across sessions
- DevTools provide powerful debugging capabilities
- Multiple stores keep code organized by domain
12. Further Resources
Official Documentation:
Plugins:
Next Steps
Outstanding! You now know how to manage global application state with Pinia.
In Lesson 5.8: Vue Best Practices & Production Build, you'll learn how to optimize your Vue app for production deployment.