Pinia State Management: The Complete Vue 3 Guide

Master Pinia, the official Vue 3 state management library. Learn stores, actions, getters, plugins, and best practices.

Mahmoud DEVO
Mahmoud DEVO
December 5, 2024 12 min read
Pinia State Management: The Complete Vue 3 Guide

Pinia is the officially recommended state management library for Vue 3. It offers a simpler, more intuitive API than Vuex while providing full TypeScript support out of the box. Let’s explore everything you need to know about Pinia.

Why Pinia Over Vuex?

FeaturePiniaVuex 4
TypeScript SupportFirst-classLimited
MutationsNo (direct state changes)Required
ModulesFlat storesNested modules
DevToolsFull supportFull support
Bundle Size~1kb~10kb
Composition APINativePlugin needed

Installation and Setup

npm install pinia
// main.ts
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')

Defining Stores

Option Store Syntax

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

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    lastUpdated: null as Date | null,
  }),

  getters: {
    doubleCount: (state) => state.count * 2,

    // Getter with parameter
    multiplyBy: (state) => {
      return (multiplier: number) => state.count * multiplier
    },
  },

  actions: {
    increment() {
      this.count++
      this.lastUpdated = new Date()
    },

    async fetchCount() {
      const response = await fetch('/api/count')
      const data = await response.json()
      this.count = data.count
    },
  },
})

Setup Store Syntax (Composition API)

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

interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

export const useUserStore = defineStore('user', () => {
  // State
  const user = ref<User | null>(null)
  const isLoading = ref(false)
  const error = ref<string | null>(null)

  // Getters
  const isAuthenticated = computed(() => user.value !== null)
  const isAdmin = computed(() => user.value?.role === 'admin')
  const displayName = computed(() => user.value?.name ?? 'Guest')

  // Actions
  async function login(email: string, password: string) {
    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')
      }

      user.value = await response.json()
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Login failed'
      throw e
    } finally {
      isLoading.value = false
    }
  }

  function logout() {
    user.value = null
  }

  async function fetchProfile() {
    if (!user.value) return

    const response = await fetch('/api/user/profile')
    const profile = await response.json()
    user.value = { ...user.value, ...profile }
  }

  return {
    // State
    user,
    isLoading,
    error,
    // Getters
    isAuthenticated,
    isAdmin,
    displayName,
    // Actions
    login,
    logout,
    fetchProfile,
  }
})

Using Stores in Components

<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// Destructure reactive refs (maintains reactivity)
const { user, isLoading, isAuthenticated, displayName } = storeToRefs(userStore)

// Actions can be destructured directly
const { login, logout } = userStore

async function handleLogin() {
  try {
    await login('user@example.com', 'password')
  } catch {
    // Handle error
  }
}
</script>

<template>
  <div v-if="isLoading">Loading...</div>

  <div v-else-if="isAuthenticated">
    <p>Welcome, {{ displayName }}!</p>
    <button @click="logout">Logout</button>
  </div>

  <div v-else>
    <button @click="handleLogin">Login</button>
  </div>
</template>

Store Composition

Stores can use other stores:

// stores/cart.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'

interface CartItem {
  productId: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()
  const productStore = useProductStore()

  const items = ref<CartItem[]>([])

  const cartTotal = computed(() => {
    return items.value.reduce((total, item) => {
      const product = productStore.getProductById(item.productId)
      return total + (product?.price ?? 0) * item.quantity
    }, 0)
  })

  const discountedTotal = computed(() => {
    // Apply user-specific discount
    const discount = userStore.isAdmin ? 0.2 : 0
    return cartTotal.value * (1 - discount)
  })

  async function checkout() {
    if (!userStore.isAuthenticated) {
      throw new Error('Must be logged in to checkout')
    }

    const response = await fetch('/api/orders', {
      method: 'POST',
      body: JSON.stringify({
        userId: userStore.user?.id,
        items: items.value,
      }),
    })

    if (response.ok) {
      items.value = []
    }
  }

  return {
    items,
    cartTotal,
    discountedTotal,
    checkout,
  }
})

Plugins

Persistence Plugin

// plugins/persistence.ts
import { PiniaPluginContext } from 'pinia'

export function piniaPersistedState({ store }: PiniaPluginContext) {
  // Load from localStorage
  const stored = localStorage.getItem(`pinia-${store.$id}`)
  if (stored) {
    store.$patch(JSON.parse(stored))
  }

  // Save on changes
  store.$subscribe((mutation, state) => {
    localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
  })
}

// main.ts
const pinia = createPinia()
pinia.use(piniaPersistedState)

Logger Plugin

// plugins/logger.ts
import { PiniaPluginContext } from 'pinia'

export function piniaLogger({ store }: PiniaPluginContext) {
  store.$onAction(({ name, args, after, onError }) => {
    const startTime = Date.now()
    console.log(`πŸš€ Action "${name}" started with args:`, args)

    after((result) => {
      console.log(
        `βœ… Action "${name}" finished in ${Date.now() - startTime}ms`,
        result
      )
    })

    onError((error) => {
      console.error(`❌ Action "${name}" failed:`, error)
    })
  })
}

Custom Properties Plugin

// plugins/api.ts
import { PiniaPluginContext } from 'pinia'
import { api } from '@/lib/api'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    $api: typeof api
  }
}

export function piniaApiPlugin({ store }: PiniaPluginContext) {
  store.$api = api
}

// Usage in store
export const useProductStore = defineStore('product', {
  actions: {
    async fetchProducts() {
      // Access $api from plugin
      const products = await this.$api.get('/products')
      this.products = products
    },
  },
})

Best Practices

1. Organize Stores by Feature

stores/
β”œβ”€β”€ auth/
β”‚   β”œβ”€β”€ index.ts       # Main auth store
β”‚   └── types.ts       # Auth-related types
β”œβ”€β”€ products/
β”‚   β”œβ”€β”€ index.ts
β”‚   └── types.ts
β”œβ”€β”€ cart/
β”‚   β”œβ”€β”€ index.ts
β”‚   └── types.ts
└── index.ts           # Re-export all stores

2. Use TypeScript Generics for Reusable Patterns

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

export function createAsyncStore<T>(
  id: string,
  fetchFn: () => Promise<T[]>
) {
  return defineStore(id, () => {
    const items = ref<T[]>([])
    const isLoading = ref(false)
    const error = ref<string | null>(null)

    const isEmpty = computed(() => items.value.length === 0)

    async function fetch() {
      isLoading.value = true
      error.value = null

      try {
        items.value = await fetchFn()
      } catch (e) {
        error.value = e instanceof Error ? e.message : 'Fetch failed'
      } finally {
        isLoading.value = false
      }
    }

    function reset() {
      items.value = []
      error.value = null
    }

    return {
      items,
      isLoading,
      error,
      isEmpty,
      fetch,
      reset,
    }
  })
}

// Usage
export const useProductStore = createAsyncStore('products', async () => {
  const response = await fetch('/api/products')
  return response.json()
})

3. Hydration for SSR

// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)

  // SSR hydration
  if (import.meta.env.SSR) {
    // Server-side: will be serialized
  } else {
    // Client-side: check for hydrated state
    const hydratedState = window.__PINIA_STATE__?.user
    if (hydratedState) {
      user.value = hydratedState.user
    }
  }

  return { user }
})

4. Testing Stores

// stores/__tests__/counter.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounterStore } from '../counter'

describe('Counter Store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('increments count', () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)

    store.increment()

    expect(store.count).toBe(1)
  })

  it('computes double count', () => {
    const store = useCounterStore()
    store.count = 5

    expect(store.doubleCount).toBe(10)
  })
})

DevTools Integration

Pinia integrates seamlessly with Vue DevTools:

  • View all stores and their state
  • Time-travel debugging
  • Edit state directly
  • Track actions and mutations

Conclusion

Pinia offers a modern, TypeScript-first approach to state management in Vue 3:

  • Simple API: No mutations, just state and actions
  • Type-safe: Full TypeScript support without workarounds
  • Modular: Flat store structure, no nested modules
  • Composable: Use stores inside other stores
  • DevTools: Full debugging support

Migrate from Vuex or start fresh β€” Pinia makes state management a pleasure.


Using Pinia in production? Share your experience in the comments!

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

Mahmoud DEVO

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles