Skip to content

Web Development · State Management

Pinia in 2026: Vue State Management After Vuex

Vuex was Vue's state management library for years. Pinia replaced it as the official recommendation and changed how Vue apps manage shared state. Here is how Pinia works and where it fits in a Vue 3 application.

Anurag Verma

Anurag Verma

7 min read

Pinia in 2026: Vue State Management After Vuex

Sponsored

Share

Vuex 4 was a reasonable state management library with one problem: it was designed for Vue 2 and retrofitted to Vue 3. The mutations/actions split made sense when Vue 2’s reactivity system required synchronous and asynchronous mutations to be handled separately. Vue 3’s Composition API made that distinction unnecessary, and the boilerplate that came with it became friction.

Pinia was designed for Vue 3 from the start. It dropped mutations, kept actions, added direct state mutation, and built TypeScript inference into its core. In 2024, the Vue team officially deprecated Vuex and endorsed Pinia. In 2026, Pinia is the default.

Here is how it works and how to use it well.

The Core Concepts

Pinia has three primitives:

  • State: the reactive data
  • Getters: computed properties derived from state
  • Actions: functions that modify state, which can be async

That’s it. No mutations, no namespacing complexity, no commit/dispatch distinction.

Defining a Store (Options API Style)

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

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: null as User | null,
    preferences: {
      theme: 'light' as 'light' | 'dark',
      language: 'en',
    },
    loading: false,
  }),

  getters: {
    displayName: (state): string => {
      return state.profile?.name ?? 'Guest'
    },
    isLoggedIn: (state): boolean => {
      return state.profile !== null
    },
  },

  actions: {
    async fetchProfile(userId: string) {
      this.loading = true
      try {
        this.profile = await api.getUser(userId)
      } finally {
        this.loading = false
      }
    },

    setTheme(theme: 'light' | 'dark') {
      this.preferences.theme = theme
    },

    logout() {
      this.$reset()  // Resets state to initial values
    },
  },
})

You can modify state directly inside actions with this. No commit('SET_LOADING', true). The TypeScript inference flows through without annotation gymnastics.

Defining a Store (Setup API Style)

Pinia also supports a Composition API style that feels closer to writing a Vue composable:

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

export const useCartStore = defineStore('cart', () => {
  // State
  const items = ref<CartItem[]>([])
  const couponCode = ref<string | null>(null)

  // Getters
  const itemCount = computed(() => items.value.length)
  const subtotal = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  // Actions
  function addItem(product: Product, quantity = 1) {
    const existing = items.value.find(i => i.id === product.id)
    if (existing) {
      existing.quantity += quantity
    } else {
      items.value.push({ ...product, quantity })
    }
  }

  function removeItem(productId: string) {
    items.value = items.value.filter(i => i.id !== productId)
  }

  async function applyCoupon(code: string) {
    const valid = await api.validateCoupon(code)
    if (valid) {
      couponCode.value = code
    }
    return valid
  }

  return { items, couponCode, itemCount, subtotal, addItem, removeItem, applyCoupon }
})

The setup style has one limitation: $reset() isn’t available by default because Pinia can’t automatically know the initial values. You either implement reset yourself as an action, or use the options style for stores where you need $reset.

Using Stores in Components

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

const userStore = useUserStore()
const cartStore = useCartStore()

// Destructuring loses reactivity — don't do this:
// const { profile } = userStore  ❌

// Use storeToRefs to destructure reactive state and getters:
const { profile, loading } = storeToRefs(userStore)
const { itemCount, subtotal } = storeToRefs(cartStore)

// Actions don't need storeToRefs — they're just functions:
const { fetchProfile, logout } = userStore
const { addItem } = cartStore
</script>

<template>
  <div>
    <p v-if="loading">Loading...</p>
    <p v-else>{{ profile?.name ?? 'Guest' }}</p>
    <p>Cart: {{ itemCount }} items, ${{ subtotal.toFixed(2) }}</p>
    <button @click="logout">Sign out</button>
  </div>
</template>

storeToRefs is the one concept that trips up developers new to Pinia. Plain destructuring works in JavaScript, but the reactive bindings are on the store object itself. storeToRefs wraps each property in a ref so it remains reactive after destructuring.

Cross-Store Interactions

Stores can use each other. Import and use inside actions:

// stores/notifications.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useNotificationStore = defineStore('notifications', {
  state: () => ({
    items: [] as Notification[],
  }),

  actions: {
    async fetchUnread() {
      const userStore = useUserStore()
      if (!userStore.isLoggedIn) return

      this.items = await api.getNotifications(userStore.profile!.id)
    },
  },
})

Import the store inside the action rather than at the top of the file to avoid circular dependency issues in large applications.

Watching Store State

In components, watch store state like any other Vue reactive source:

import { watch } from 'vue'

const userStore = useUserStore()

watch(() => userStore.preferences.theme, (newTheme) => {
  document.documentElement.setAttribute('data-theme', newTheme)
}, { immediate: true })

Alternatively, use Pinia’s $subscribe to react to any state change in a store:

userStore.$subscribe((mutation, state) => {
  localStorage.setItem('userPreferences', JSON.stringify(state.preferences))
})

Plugins

Pinia’s plugin system handles cross-cutting concerns like persistence, undo/redo, and error logging. A plugin is a function that receives a context object and can add properties to all stores:

// plugins/persistPlugin.ts
import type { PiniaPluginContext } from 'pinia'

export function persistPlugin({ store }: PiniaPluginContext) {
  const key = `store_${store.$id}`
  const saved = localStorage.getItem(key)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  store.$subscribe((_mutation, state) => {
    localStorage.setItem(key, JSON.stringify(state))
  })
}

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

For production persistence, pinia-plugin-persistedstate is the standard choice. It handles serialization edge cases, selective persistence (only persist specific fields), and pluggable storage backends.

Pinia vs Vuex: The Actual Differences

PiniaVuex 4
MutationsNoYes (required for sync changes)
TypeScriptFirst-class, inferredRequires extra typing effort
Bundle size~1.5 KB~6.5 KB
DevtoolsFull Vue Devtools supportFull Vue Devtools support
SSRSupportedSupported
NamespacingFlat — each store is its own unitExplicit module nesting
$reset()Built-in for options styleNot built-in
Setup storesYesNo

The namespacing question comes up most often when teams move from Vuex. Vuex modules created hierarchical namespaces like store.dispatch('users/fetchProfile'). Pinia doesn’t have namespacing — each store is identified by its ID string, and you import and use each store separately. In practice this is cleaner; the “namespace” is the store name in the import.

When Pinia Isn’t the Answer

For simple applications — a landing page with a counter, a contact form — Pinia is overkill. A single ref or reactive in a composable handles shared state fine.

// composables/useAuth.ts — sometimes this is enough
import { ref } from 'vue'

const currentUser = ref<User | null>(null)

export function useAuth() {
  const login = async (credentials: Credentials) => {
    currentUser.value = await api.login(credentials)
  }
  const logout = () => {
    currentUser.value = null
  }
  return { currentUser, login, logout }
}

Because currentUser is declared at module scope (outside the function), it’s shared across all component instances that call useAuth(). This is module-level shared state — simple, no library required, works fine for small cases.

Pinia earns its keep when you need devtools support, SSR-safe state, store composition, plugins, or when the application has enough state that organizing it into named stores becomes valuable.

In 2026, starting a Vue 3 application means defaulting to Pinia. The question isn’t whether to use it — it’s how to structure your stores to keep them focused and avoid the catch-all store that was Vuex’s most common antipattern.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored