Vue.js Pinia State Management

Vue.js Pinia State Management

Pinia is the official state management library for Vue.js, designed to be simple, intuitive, and type-safe. It’s the recommended replacement for Vuex and works seamlessly with both the Composition API and Options API.

What is Pinia?

Pinia is a state management library that provides:

  • Intuitive API design
  • Excellent TypeScript support
  • Devtools integration
  • Hot module replacement support
  • No mutations needed
  • Code splitting support

Installation

Install Pinia in your Vue.js project:

npm install pinia
# or
yarn add pinia
# or
pnpm add pinia

Basic Setup

First, create a Pinia instance and add it to your Vue app:

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

Creating a Store

Stores are defined using defineStore(). Here’s a basic counter store:

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

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    
    // Can accept other getters as arguments
    tripleCount: () => this.doubleCount * 3
  },
  
  actions: {
    increment() {
      this.count++
    },
    
    decrement() {
      this.count--
    },
    
    reset() {
      this.count = 0
    },
    
    // Async actions
    async fetchCount() {
      try {
        const response = await fetch('/api/count')
        const data = await response.json()
        this.count = data.count
      } catch (error) {
        console.error('Failed to fetch count:', error)
      }
    }
  }
})

Using Stores in Components

Composition API Usage

<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.reset()">Reset</button>
    <button @click="counter.fetchCount()">Fetch</button>
  </div>
</template>

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

const counter = useCounterStore()
</script>

Options API Usage

<template>
  <div>
    <h2>{{ name }}</h2>
    <p>Count: {{ count }}</p>
    <p>Double: {{ doubleCount }}</p>
    
    <button @click="increment">+</button>
    <button @click="decrement">-</button>
  </div>
</template>

<script>
import { useCounterStore } from '@/stores/counter'
import { mapState, mapActions } from 'pinia'

export default {
  computed: {
    ...mapState(useCounterStore, ['count', 'name', 'doubleCount'])
  },
  methods: {
    ...mapActions(useCounterStore, ['increment', 'decrement'])
  }
}
</script>

Store Composition

Setup Store Syntax (Composition API Style)

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

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref(null)
  const isLoggedIn = computed(() => !!user.value)
  
  // Getters
  const displayName = computed(() => {
    return user.value ? `${user.value.firstName} ${user.value.lastName}` : 'Guest'
  })
  
  // Actions
  function login(userData) {
    user.value = userData
  }
  
  function logout() {
    user.value = null
  }
  
  async function fetchUser(userId) {
    try {
      const response = await fetch(`/api/users/${userId}`)
      const userData = await response.json()
      login(userData)
    } catch (error) {
      console.error('Failed to fetch user:', error)
    }
  }
  
  return {
    // State
    user,
    
    // Getters
    isLoggedIn,
    displayName,
    
    // Actions
    login,
    logout,
    fetchUser
  }
})

Store with TypeScript

Pinia provides excellent TypeScript support:

// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface User {
  id: number
  firstName: string
  lastName: string
  email: string
}

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  const isLoggedIn = computed(() => !!user.value)
  const fullName = computed(() => {
    return user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
  })
  
  async function login(credentials: { email: string; password: string }) {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      })
      
      if (!response.ok) {
        throw new Error('Login failed')
      }
      
      const userData: User = await response.json()
      user.value = userData
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Unknown error'
    } finally {
      loading.value = false
    }
  }
  
  function logout() {
    user.value = null
    error.value = null
  }
  
  return {
    user,
    loading,
    error,
    isLoggedIn,
    fullName,
    login,
    logout
  }
})

Store Plugins

Create plugins to extend store functionality:

// plugins/logger.js
export function loggerPlugin({ store }) {
  store.$subscribe((mutation, state) => {
    console.log(`Store "${store.$id}" changed:`, {
      mutation,
      newState: state
    })
  })
}

// plugins/persistence.js
export function persistencePlugin({ store, options }) {
  const key = `pinia-${store.$id}`
  
  // Load state from localStorage
  const savedState = localStorage.getItem(key)
  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }
  
  // Save state to localStorage
  store.$subscribe((mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  })
}

Use plugins when creating the Pinia instance:

// main.js
import { createPinia } from 'pinia'
import { loggerPlugin, persistencePlugin } from './plugins'

const pinia = createPinia()
pinia.use(loggerPlugin)
pinia.use(persistencePlugin)

Store Composition and Modules

Organize related stores in modules:

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

export const useAuthStore = defineStore('auth', () => {
  const token = ref(localStorage.getItem('token'))
  const user = ref(null)
  
  const isAuthenticated = computed(() => !!token.value)
  
  function setToken(newToken) {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }
  
  function clearToken() {
    token.value = null
    user.value = null
    localStorage.removeItem('token')
  }
  
  return {
    token,
    user,
    isAuthenticated,
    setToken,
    clearToken
  }
})

// stores/products.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useProductsStore = defineStore('products', () => {
  const products = ref([])
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchProducts() {
    loading.value = true
    error.value = null
    
    try {
      const response = await fetch('/api/products')
      products.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  return {
    products,
    loading,
    error,
    fetchProducts
  }
})

Advanced Patterns

Store Composables

Create composables that work with multiple stores:

// composables/useShoppingCart.js
import { computed } from 'vue'
import { useProductsStore } from '@/stores/products'
import { useAuthStore } from '@/stores/auth'
import { useCartStore } from '@/stores/cart'

export function useShoppingCart() {
  const productsStore = useProductsStore()
  const authStore = useAuthStore()
  const cartStore = useCartStore()
  
  const cartItems = computed(() => {
    return cartStore.items.map(item => ({
      ...item,
      product: productsStore.products.find(p => p.id === item.productId)
    }))
  })
  
  const cartTotal = computed(() => {
    return cartItems.value.reduce((total, item) => {
      return total + (item.product?.price || 0) * item.quantity
    }, 0)
  })
  
  const canCheckout = computed(() => {
    return authStore.isAuthenticated && cartItems.value.length > 0
  })
  
  async function addToCart(productId, quantity = 1) {
    if (!authStore.isAuthenticated) {
      throw new Error('Must be logged in to add items to cart')
    }
    
    cartStore.addItem(productId, quantity)
  }
  
  return {
    cartItems,
    cartTotal,
    canCheckout,
    addToCart
  }
}

Dynamic Stores

Create stores dynamically:

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

export function createDynamicStore(storeId, initialState = {}) {
  return defineStore(storeId, {
    state: () => ({
      ...initialState,
      lastUpdated: null
    }),
    
    actions: {
      updateData(newData) {
        Object.assign(this.$state, newData)
        this.lastUpdated = new Date().toISOString()
      }
    }
  })
}

// Usage
const userStore = createDynamicStore('user', { name: '', email: '' })
const settingsStore = createDynamicStore('settings', { theme: 'light' })

Testing Stores

Test your Pinia stores:

// tests/stores/counter.test.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  it('increments count', () => {
    const counter = useCounterStore()
    
    expect(counter.count).toBe(0)
    
    counter.increment()
    
    expect(counter.count).toBe(1)
  })
  
  it('computes double count', () => {
    const counter = useCounterStore()
    
    counter.count = 5
    
    expect(counter.doubleCount).toBe(10)
  })
  
  it('resets to initial state', () => {
    const counter = useCounterStore()
    
    counter.count = 10
    counter.reset()
    
    expect(counter.count).toBe(0)
  })
})

Best Practices

  1. Use setup syntax for better TypeScript support and composability
  2. Keep stores focused on specific domains
  3. Use composables to combine multiple stores
  4. Prefer actions over direct state mutations
  5. Handle loading and error states in async operations
  6. Use TypeScript for better development experience
  7. Test your stores separately from components

Migration from Vuex

If you’re migrating from Vuex, here’s a quick comparison:

VuexPinia
statestate (or ref() in setup)
gettersgetters (or computed() in setup)
mutationsActions (no mutations needed)
actionsactions (can directly modify state)
modulesSeparate store files
mapState, mapGettersDirect store access or composables

External Resources:

Related Tutorials:

Last updated on