Skip to content

Web Development · State Management

Pinia in 2026: Vue State Management After Vuex

Vuex was Vue's state library for years, and Pinia replaced it as the official recommendation. How Pinia works, and where it fits in a Vue 3 app.

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.

Frequently asked questions

Why did Pinia remove mutations?
Because the reason they existed went away. In Vuex, mutations had to be synchronous so the devtools could track state changes reliably against Vue 2's reactivity system, which forced the commit and dispatch split and all the boilerplate around it. Vue 3's reactivity tracks direct mutation fine, so actions can just assign to state and devtools still see it. What you lose is a formal audit point for every change; what you gain is roughly half the code.
When do I need storeToRefs?
Whenever you destructure state or getters out of a store in a component. The reactive bindings live on the store object itself, so const { count } = useCounter() copies the value once and stops updating. storeToRefs wraps each property in a ref so destructuring keeps reactivity. Actions are plain functions and do not need it, so destructure those directly.
Should I use options stores or setup stores?
Options stores if you want $reset() for free and a shape that reads like Vuex without the mutations. Setup stores if the store needs composables, watchers, or logic that is awkward to express as a plain object. The practical split most teams land on is options stores for straightforward data holders and setup stores for anything with real behaviour inside it.
How do I handle stores that depend on each other?
Import and call the other store inside the action that needs it, not at module top level. Top-level imports create circular dependency problems as soon as two stores reference each other, and those failures are confusing because they surface as undefined stores rather than import errors. Importing inside the action defers resolution to call time, which sidesteps it.
When is Pinia overkill?
When a module-scope ref inside a composable would do. Declare the ref outside the exported function and every component calling that composable shares it, which covers a logged-in user or a theme toggle without any library. Pinia earns its place when you want devtools inspection, SSR-safe state, plugins like persistence, or enough state that naming and separating stores is itself valuable.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored