Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 015 / 100

Pinia state management: a practical guide for developers

The official store for Vue.

Expona Admin7 min read

By day 15 of learning Vue and Nuxt, you've probably felt the pain of prop drilling and shared state across components. Pinia state management solves this elegantly, giving your team a predictable, scalable way to manage application data. We'll walk through Pinia from first principles—defining stores, consuming them, keeping reactivity intact, and debugging with DevTools—so you can confidently handle state in production applications.

What is Pinia and why it matters

Pinia is Vue's official state management library, designed as the spiritual successor to Vuex. Unlike Vuex, Pinia stores feel like plain JavaScript functions (composables), which makes them more intuitive for developers coming from a compositional API background. There's no boilerplate around mutations, actions, or dispatches—just reactive state, computed properties, and functions that modify that state.

In production systems handling cart operations, user authentication, or real-time trading data, Pinia ensures that multiple components stay in sync without manually passing props down and events up. You define your store once and access it anywhere in your application.

Define a store—setup stores feel like composables

A Pinia store in setup syntax is built with defineStore, which takes a name and a function. Inside that function, you declare refs (reactive state), computed properties (derived state), and functions (actions). The function returns an object with everything you want to expose.

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

export interface Item {
  id: string
  name: string
  price: number
  quantity: number
}

export const useCart = defineStore('cart', () => {
  const items = ref<Item[]>([])
  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )

  function add(item: Item) {
    const existing = items.value.find(i => i.id === item.id)
    if (existing) {
      existing.quantity += item.quantity
    } else {
      items.value.push(item)
    }
  }

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

  function clear() {
    items.value = []
  }

  return { items, total, add, remove, clear }
})

Notice how straightforward this is: state lives in refs, derived values in computed, and side effects or modifications happen in regular functions. There's no ceremony, no dispatching actions or committing mutations. When items changes, total automatically recalculates because it's a computed property depending on it.

For a real-world example, imagine your e-commerce platform needs to manage not just cart state but also applied coupons, shipping info, and tax calculations. You'd add those as additional refs and computed properties within the same store, keeping related logic together.

Use it—access stores in any component

Once you've defined a store, consuming it is trivial. Call the store function (e.g., useCart()) anywhere—in a component, another composable, or even a plugin. The store is automatically a singleton within your Pinia instance, so all components share the same state.

typescript
// src/components/ProductCard.vue
<template>
  <div class="product-card">
    <h3>{{ product.name }}</h3>
    <p>Price: ${{ product.price }}</p>
    <button @click="handleAddToCart">Add to Cart</button>
  </div>
</template>

<script setup lang="ts">
import { useCart } from '@/stores/cart'

const cart = useCart()

const props = defineProps<{
  product: { id: string; name: string; price: number }
}>()

function handleAddToCart() {
  cart.add({
    id: props.product.id,
    name: props.product.name,
    price: props.product.price,
    quantity: 1
  })
}
</script>

In another component, say your cart summary, you'd use the same store and display cart.items and cart.total:

typescript
// src/components/CartSummary.vue
<template>
  <div class="cart-summary">
    <h2>Cart ({{ cart.items.length }} items)</h2>
    <ul>
      <li v-for="item in cart.items" :key="item.id">
        {{ item.name }} x {{ item.quantity }} = ${{ item.price * item.quantity }}
      </li>
    </ul>
    <p><strong>Total: ${{ cart.total }}</strong></p>
    <button @click="cart.clear()">Clear Cart</button>
  </div>
</template>

<script setup lang="ts">
import { useCart } from '@/stores/cart'

const cart = useCart()
</script>

Both components now reference the same store instance. When you add an item in the ProductCard, it immediately shows up in the CartSummary. No event emitting, no prop passing—the reactive system handles synchronization.

storeToRefs—destructure without losing reactivity

A common mistake is destructuring the store directly: const { items, total } = useCart(). While this looks clean, it breaks reactivity because you're extracting plain values, not refs. If you mutate items directly, Vue won't track the change.

The solution is storeToRefs, which unwraps the reactivity correctly:

typescript
// src/components/CartList.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCart } from '@/stores/cart'

const cart = useCart()
const { items, total } = storeToRefs(cart)

// items and total remain reactive
// You can also destructure actions without storeToRefs
const { add, remove } = cart
</script>

Why the distinction? Refs need to stay wrapped to maintain reactivity. storeToRefs unwraps the internal refs so your template and watchers see the correct reactive objects. Actions are just functions, so you can destructure them safely without storeToRefs.

In production systems, we often build helper composables that wrap store operations. Using storeToRefs ensures those helpers maintain reactivity when passed to child components:

typescript
// src/composables/useCartOperations.ts
import { storeToRefs } from 'pinia'
import { useCart } from '@/stores/cart'

export function useCartOperations() {
  const cart = useCart()
  const { items, total } = storeToRefs(cart)

  function addItem(product: any) {
    cart.add({ ...product, quantity: 1 })
  }

  function removeItem(id: string) {
    cart.remove(id)
  }

  return { items, total, addItem, removeItem }
}

DevTools—inspect and time-travel store state

Pinia integrates seamlessly with Vue DevTools, letting you inspect store state in real time and replay state changes. This debugging capability becomes invaluable when tracking down where a state mutation happened or understanding the sequence of events leading to a bug.

Install the Vue DevTools browser extension, then open it while your app runs. You'll see a "Pinia" tab showing all active stores, their current state, and a history of every action that modified state. Click any past state to time-travel and see what the app looked like at that moment.

DevTools Feature What it does When to use
State inspector View current state values for all stores Verify state matches expectations after user actions
Action history See every action called with parameters and timing Track the sequence of operations, identify performance bottlenecks
Time-travel debugging Jump to any point in the action history Reproduce bugs by stepping backward through state changes
State diffing See exactly what changed between actions Understand side effects, catch unintended mutations

In a ride-hailing application, for example, you might use time-travel to debug a race condition where a user's ride request is somehow processed twice. By replaying the action history, you'd see exactly when and why the duplicate request was created.

DevTools is opt-in during development. In production builds, the DevTools integration is stripped out, so there's no performance penalty.

Common mistakes

  • Mutating state without refs: Writing store.items = [] instead of store.items.value = [] breaks reactivity in some cases. Always use refs for mutable state.
  • Forgetting storeToRefs when destructuring: As mentioned, const { items } = useCart() loses reactivity. Use const { items } = storeToRefs(useCart()).
  • Creating stores outside of setup: Stores must be created within a Pinia-enabled context. In Nuxt, define stores in the stores/ directory. In vanilla Vue, ensure Pinia is installed as a plugin before using any store.
  • Overloading a single store: If your store becomes huge with dozens of properties, split it into smaller stores by feature. A cart store, a user store, and a notifications store are easier to reason about than one monolithic store.
  • Not typing your state: Use TypeScript interfaces for items, like Item[], to catch bugs early and provide IDE autocomplete.

Key takeaways

  • Pinia stores are composables that return reactive state, computed properties, and functions—no mutations or actions infrastructure.
  • Access any store from any component by calling its function; stores are singletons within your Pinia instance.
  • Use storeToRefs when destructuring state properties to maintain reactivity across your component tree.
  • Vue DevTools provides real-time inspection and time-travel debugging, making state issues straightforward to diagnose.
  • Keep stores focused by feature (cart, auth, notifications) rather than mixing concerns in a giant store.
  • Type your store state with TypeScript interfaces for better IDE support and fewer runtime bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles