Building State Management with Pinia the Right Way
AI generated
<v/>
{ }
Vue 3 · Pinia · State Management · Composition API
Building State Management with Pinia the Right Way
from the first store definition to a scalable architecture

Pinia is not just Vuex 5 under a different name, it is a fundamentally different approach that consistently embraces the Composition API. Anyone who uses Pinia as a mere data store is leaving the potential of store composition, type safe actions and reactive getters on the table. This article shows how to build state management with Pinia in Vue 3 correctly from the ground up.

18 min read defineStore · Actions · Getters · Store Composition · DevTools Vue 3.4+ · Pinia 2.x · TypeScript

1. Why Pinia and Not Vuex

The Vue community settled on Pinia as the official state management standard for good reasons. Vuex 4 was a direct port of Vuex 3 to Vue 3 that did not consistently embrace the Composition API. Mutations as a separate layer between state and actions were a historical artifact, in Vue Devtools they made it possible to track every state transition. With Vue 3 and its proxy based reactivity, that separation is no longer necessary: Pinia tracks state changes directly, without mutations as a detour. The result is less boilerplate and more type safety.

The second decisive advantage of Pinia over Vuex is a modular store system without the namespacing chaos. In Vuex, modules had to be configured with namespaced: true, and accessing other modules required the store root as context. In Pinia, every store is its own unit that can be imported directly and used in other stores or components. That makes store composition natural and intuitive, without root store references or string based dispatch calls.

TypeScript support in Vuex was always an afterthought that was never fully integrated. Pinia was designed with TypeScript in mind from day one: state, getters and actions are typed correctly and automatically, without manual type declarations for the store. That reduces maintenance effort considerably and makes refactoring safe, because the TypeScript compiler automatically finds every usage site.

2. Installation and Setup in Vue 3

Pinia is installed as a separate package and registered as a Vue plugin. The integration in main.ts is minimal: createPinia() creates the Pinia instance, app.use(pinia) registers it. After that, every store is immediately available in every component, no manual registration of individual stores, no root store object with nested modules. Each store file is imported on demand and initialized automatically.

In projects with server side rendering, for example with Nuxt 3, a fresh Pinia instance is created per request to prevent state from leaking between requests. Nuxt 3 handles this automatically via the @pinia/nuxt module. In pure SPA projects, a single global instance is enough. The Vue Devtools integrate with Pinia automatically after installation and show every store with its current state, its change history and the actions that were executed in the timeline view.


// main.ts - Pinia setup in Vue 3
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
const pinia = createPinia()

// Pinia plugins must be added before app.use(pinia)
// Example: pinia-plugin-persistedstate for localStorage sync
// import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
// pinia.use(piniaPluginPersistedstate)

app.use(pinia)
app.use(router)
app.mount('#app')

3. Store Definition: Options vs. Setup Style

Pinia supports two syntax variants for defining stores. The Options style follows the Vuex API and Vue's Options API: an object with state, getters and actions as keys. The Setup style uses the Composition API directly: inside a function, ref() and computed() values as well as regular functions are defined and returned as an object at the end. Both variants produce identical stores with identical behavior, the difference is purely stylistic.

In TypeScript projects, the Setup style is recommended, because types are derived directly from the ref() and computed() declarations. No separate interface for the store state is needed. The Options style makes sense for teams migrating from Vuex who prefer a familiar structure. Mixing both styles in the same project is entirely fine and does not create technical debt. Important: the first parameter of defineStore is the store ID, it must be unique across the whole project and appears in the DevTools.

4. State: Modeling Reactive Data Cleanly

The state of a Pinia store is the only place for reactive data. That sounds trivial but is the most common source of bugs in growing projects: state gets duplicated, once in the store, once as a local ref() in a component, and synchronization becomes a manual chore. The rule is clear: anything that is read or changed by more than one component belongs in the Pinia store. Anything that is exclusively relevant to a single component stays as ref() or reactive() inside that component.

State objects should be kept as flat as possible. Deep nesting makes partial updates cumbersome and complicates debugging in the DevTools, because the change history becomes harder to read. For complex data structures, a normalized state with ID based maps is recommended, similar to Redux's entities pattern. Pinia supports $patch() for atomic partial updates that appear as a single state transition in the DevTools, instead of a series of individual assignments.

5. Getters: Derived Data Without Redundancy

Getters in Pinia correspond to computed() properties: they are only recalculated when the state values they depend on change, and their results are cached. That makes them the right tool for all derived data, filtered lists, aggregated numbers, formatted values. The common mistake: repeating getter logic across components instead of defining a single central getter. That leads to inconsistencies whenever the calculation logic changes.

Getters can read other getters of the same store as well as the state. In the Setup style, these are simply computed() calls that access other computed() or ref() values. In the Options style, every getter receives the state as a parameter. Getters can also be parameterized by returning a function, but then automatic caching no longer applies, because the result depends on the parameter. For parameterized queries, it is often better to define an action that caches the result in a map inside the state.


// stores/cart.ts - Setup-style Pinia store with getters and actions
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { CartItem, Product } from '@/types'

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

  // --- Getters (computed = cached, reactive) ---
  const totalItems = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0)
  )

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

  const discountedTotal = computed(() =>
    couponCode.value === 'SAVE10' ? subtotal.value * 0.9 : subtotal.value
  )

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

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

  // Atomic patch: single history entry in DevTools
  function clearCart() {
    items.value = []
    couponCode.value = null
  }

  return { items, couponCode, isLoading, totalItems, subtotal, discountedTotal, addItem, removeItem, clearCart }
})

6. Actions: Async Logic and Error Handling

Actions are the place for all business logic in the Pinia store. They can be synchronous or asynchronous and have direct access to state and getters. Unlike Vuex, there are no mutations: state changes inside actions are direct assignments to ref() values. Pinia tracks these changes through the Vue reactivity proxy and displays them correctly in the DevTools. That means less code, no mutation names as magic strings, and no more two step dispatch commit process.

Error handling in asynchronous actions is a frequently neglected aspect. The pattern: an isLoading flag and an error ref in the state, set in every async action block and reset on completion. With try/catch/finally, isLoading is guaranteed to be reset correctly even on errors. Components subscribe to the store's error state and display error messages without having to maintain their own error state.

7. Store Composition: Connecting Stores Cleanly

Store composition is one of the most powerful features of Pinia and the main difference from the Vuex module architecture. A Pinia store can import other stores directly and use their state and actions. A useOrderStore can import useCartStore and read its items, without needing a root store as a bridge. That enables a natural dependency structure between stores that matches the application's data flow.

Something important in store composition: avoid circular dependencies. If store A imports store B and store B imports store A, a module resolution problem arises. The pattern for such cases is a third store that holds the shared data, or extracting the shared logic into a composable function that both stores use. Pinia does not force a flat store hierarchy, the architecture should follow the application's domain model.


// stores/order.ts - Store composition: reads from cartStore
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useCartStore } from './cart'
import { useAuthStore } from './auth'
import { apiClient } from '@/api'
import type { Order } from '@/types'

export const useOrderStore = defineStore('order', () => {
  const orders = ref<Order[]>([])
  const isSubmitting = ref(false)
  const lastError = ref<string | null>(null)

  async function submitOrder(shippingAddress: string) {
    // Compose from other stores - no root-store needed
    const cart = useCartStore()
    const auth = useAuthStore()

    if (!auth.isLoggedIn) throw new Error('User not authenticated')
    if (cart.items.length === 0) throw new Error('Cart is empty')

    isSubmitting.value = true
    lastError.value = null

    try {
      const order = await apiClient.post<Order>('/orders', {
        items: cart.items,
        total: cart.discountedTotal,
        userId: auth.userId,
        shippingAddress,
      })
      orders.value.unshift(order)
      // Cross-store side effect: clear cart after successful order
      cart.clearCart()
      return order
    } catch (err) {
      lastError.value = err instanceof Error ? err.message : 'Unknown error'
      throw err
    } finally {
      isSubmitting.value = false
    }
  }

  return { orders, isSubmitting, lastError, submitOrder }
})

8. Persistence and the Plugin System

Pinia offers a plugin system that lets you equip every store globally with extra properties or behavior. The best known plugin is pinia-plugin-persistedstate, which automatically saves selected parts of the state in localStorage or sessionStorage and restores them when the app loads. Configuration happens directly in the store definition via a persist option, without manual serialize/deserialize logic in every action that changes state.

For more complex requirements, server side state rehydration, encrypting sensitive data, logging every state transition, the plugin system is the right approach. A plugin is a function that receives the store context and can register store properties or $onAction hooks. $onAction is Pinia's equivalent of Redux middleware: every action is intercepted before and after execution, which can be used for logging, analytics and error reporting. That keeps tracking logic out of the stores and makes it maintainable in a single central plugin.

9. Pinia Patterns Compared

Choosing the right Pinia architecture depends on project size and requirements. Here are the most important patterns and when to use them.

Scenario Bad Solution Recommended Pinia Pattern Benefit
Shared data Props drilling through 4 levels Pinia store with ref() Every component accesses it directly
Derived values Calculation in every component Getter with computed() Cached, defined once
API call fetch() directly in the component Async action in the store State, loading, error centralized
Store communication Event bus or global variable Store composition Type safe, traceable
Persistence localStorage in every action pinia-plugin-persistedstate Automatic, configurable

A common architecture mistake is creating too many granular stores. A store per entity looks tidy at first, but leads to store composition becoming necessary where a single cohesive store unit would have been enough. The rule of thumb: structure stores by domain bounded contexts, useCartStore, useAuthStore, useProductStore, not by UI views or components. State that belongs to a single view only should stay in local component state.

Mironsoft

Vue 3 Frontend Architecture and State Management with Pinia

Want to build a Pinia architecture for your Vue 3 project?

We help build a clean Pinia store architecture, from the first store definition to store composition, plugin integration and TypeScript type safety.

Store Architecture

Domain oriented stores, clean composition without circular dependencies

TypeScript Integration

Full type safety in state, getters and actions without manual declarations

Vuex Migration

Migrate existing Vuex stores to Pinia step by step without feature freezes

10. Summary

Pinia is the right choice for state management in Vue 3, because it embraces the Composition API consistently instead of working around it. The key principles: structure stores by domain, not by UI view. Keep state flat and use $patch() for atomic updates. Use getters for all derived data, never duplicate calculation logic across components. Equip async actions with isLoading and error state. Use store composition instead of an event bus for store communication.

The plugin system makes Pinia extensible for cross cutting concerns like persistence, logging and analytics. pinia-plugin-persistedstate solves localStorage integration without boilerplate in every action. $onAction hooks in custom plugins are the clean place for tracking and error reporting. TypeScript support is not a bolt on, it is a design intrinsic part of the API, anyone using TypeScript gets full type safety from the store definition without extra declarations.

Pinia State Management - The Essentials at a Glance

Store Structure

Setup style with ref(), computed() and functions, straight from the Composition API, fully typed without manual interfaces.

Actions & Errors

Async actions with isLoading and error in the state. try/catch/finally ensures correct flag reset even on errors.

Composition

Stores import other stores directly, no root store, no namespacing, no string based dispatch calls.

Plugins & Persistence

pinia-plugin-persistedstate for localStorage. Define $onAction hooks for logging and analytics centrally in the plugin.

11. FAQ: State Management with Pinia

1What is the difference between Pinia and Vuex?
Pinia does away with mutations, supports TypeScript natively, has no namespacing and uses the Composition API. Stores import each other directly, no root store needed.
2Options style or Setup style?
Setup style for TypeScript projects, types are derived from ref() and computed(). Options style for teams migrating from Vuex. Both styles can be mixed.
3Store to store communication?
Import the store directly and use its state and actions. No root store, no namespacing. Resolve circular dependencies through a third store or a composable.
4Persist state in localStorage?
Install pinia-plugin-persistedstate and register it with pinia.use(). Specify persist: true or a configuration object with paths in the store definition.
5Use Pinia with Nuxt 3?
Install the @pinia/nuxt module. It creates a new Pinia instance per SSR request and hydrates the state on the client automatically.
6What is $patch()?
An atomic update of several state properties recorded as a single DevTools entry. Better than individual direct assignments for complex updates.
7Unit testing Pinia stores?
createPinia() per test, setActivePinia(). Call actions directly, mock the API with vi.mock() or MSW. Clean up with pinia.$dispose() after the test.
8storeToRefs(), when is it needed?
When destructuring state and getters. Direct destructuring loses reactivity. Actions can be destructured without storeToRefs().
9Pinia plugins, when are they useful?
For persistence, logging, analytics and error reporting, cross cutting concerns that affect all stores and do not belong in individual store actions.
10Avoiding too many granular stores?
Structure stores by domain bounded contexts. Local component state stays in ref() inside the component. One store per entity is usually too granular.