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:

  1. Props - Only works for parent-child (not siblings)
  2. Events - Only works upward (child to parent)
  3. Provide/Inject - Better, but limited features
  4. 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

FeaturePiniaVuex
APISimpler, intuitiveMore boilerplate
TypeScriptExcellentGood
DevToolsExcellentGood
Composition APINative supportRequires adapter
Bundle SizeSmallerLarger
Official StatusOfficial (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:

  1. Timeline: See all state mutations
  2. Inspector: View current store state
  3. Time Travel: Revert to previous states
  4. 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:

  1. Theme (light/dark)
  2. Language selection
  3. Font size
  4. Persist to localStorage
  5. Apply preferences on load

Exercise 5.7.2: Products and Cart

Build e-commerce stores:

  1. Products store (fetch from API)
  2. Cart store (add/remove/update)
  3. Favorites store (wishlist)
  4. Sync cart across tabs (localStorage events)

Exercise 5.7.3: Notifications System

Create notification store:

  1. Add notifications (info/success/warning/error)
  2. Auto-dismiss after timeout
  3. Maximum number of notifications
  4. Pause on hover

Exercise 5.7.4: Multi-User Todo App

Build collaborative todos:

  1. Auth store (login/logout)
  2. Todos store (CRUD operations)
  3. Filter by user
  4. 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() or reactive()
  • 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.