Vue Composable Stores vs. Pinia Stores: Which Pattern When?
AI generated
<v/>
{ }
Vue 3 · Pinia · State Management · Composables
Composable Stores vs. Pinia Stores
Which pattern in Vue 3, and when?

"Do we need Pinia here, or is a composable enough?" is one of the most common architecture questions in Vue 3 projects. The answer determines whether global state is centralized or scattered, visible or hidden, DevTools-trackable or invisible. Choosing the wrong approach costs months of debugging effort once the application grows.

14 min read Pinia · defineStore · useStore · reactive · provide/inject Vue 3.x · Pinia 2.x · TypeScript

1. The core problem: when is state "global"?

The distinction between local and global state is the foundation of every state management decision in Vue. Local state belongs to a component and is not relevant to other parts of the application, a dropdown open state, a temporary input value, the loading indicator of a single button. Global state is state that several independent parts of the application access and mutate at the same time, authentication, shopping cart, user preferences, notification queue.

The decision for Pinia or a simple composable primarily depends on this question: does this state need to be used by unconnected components at the same time? If yes, Pinia is the structurally correct choice. If no, meaning prop drilling or provide/inject distribute the state adequately, a local composable state or a composable store is the lighter-weight alternative. Choosing the wrong pattern is costly either way: too much Pinia creates unnecessary complexity and boilerplate, too little Pinia leads to state synchronization problems between components.

A third state exists in between: the composable store. It is a Vue composable that holds reactive state in module scope, outside the component instance. Because module scope is a singleton, every importer shares the same state. That is a powerful but invisible pattern that works well in small projects, but can cause state contamination across requests in SSR.

2. The composable store: reactive state outside components

A composable store exploits the fact that Vue reactivity is not limited to components. A ref or reactive created in the module scope of a TypeScript file is reactive and outlives component instances. When several components import the same composable, they access the same ref, the state held in module scope. That gives you simple, lightweight state sharing without any external library.

The pattern looks like a normal composable, but has reactive state outside the function. The function itself then becomes just a factory that accesses the shared state and exposes methods. This composable store pattern works well for state that must be shared application-wide but does not need DevTools integration, persistence, or time-travel debugging. For teams with strong Vue DevTools workflows, the invisibility of composable store state is a real problem.

The critical limitation of the composable store pattern is SSR. In a server-rendering environment (Nuxt), module scope is initialized per server process, not per request. That means state from a previous request can leak into the next request. Pinia solves this problem structurally through request-scoped state, every SSR request gets a fresh Pinia instance.


// src/stores/useNotifications.ts
// Composable Store: module-scope reactive state (singleton, NOT SSR-safe)
import { ref, readonly } from 'vue'

interface Notification { id: number; message: string; type: 'success' | 'error' | 'info' }

// Module-scope state, shared across all imports
const notifications = ref<Notification[]>([])
let nextId = 1

// All consumers share the same reactive state
export function useNotifications() {
  function add(message: string, type: Notification['type'] = 'info') {
    notifications.value.push({ id: nextId++, message, type })
  }

  function remove(id: number) {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }

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

  return {
    notifications: readonly(notifications), // expose as readonly
    add,
    remove,
    clear,
  }
}

3. Pinia: defineStore, state, getters and actions

Pinia is the official state management solution for Vue 3 and replaces Vuex. Its central building block is defineStore(), which takes a unique store name (ID), the initial state as a function, getters as computed-like properties, and actions as methods. The store ID is not just a name, it is the key in the global Pinia instance and the identifier shown in the Vue DevTools.

The options-store style of Pinia resembles the Vue 2 Options API: state, getters and actions as separate objects. That is the easier entry point for teams coming from Vuex. Inside actions you have direct access to this, the store itself, and can call other stores with useOtherStore(). Pinia supports a plugin system, so persistence, reset functionality, or logging can be implemented once as a plugin and enabled for all stores.

A decisive advantage of Pinia over the composable store pattern is full TypeScript inference without manual type annotations. The state type is inferred from the state() function, getter types from the return types of the getter functions, action parameters from the action signatures. The DevTools panel shows every active Pinia store with its current state, its mutations and time-travel snapshots, an enormous debugging advantage over invisible module-scope state.

4. Pinia setup stores: composables as store definitions

Pinia has supported a second definition style since version 2: setup stores. Instead of an options object, you pass defineStore a function built exactly like a Vue composable, with ref, computed, watch and an explicit return statement. That is the best of both worlds: the structure of composables combined with the Pinia infrastructure (DevTools, plugins, SSR safety).

Setup stores let you "lift" existing composables into a Pinia store. A useAuth composable that has grown big enough for a team to justify DevTools integration can be converted one-to-one into a Pinia setup store, the code stays nearly identical. That makes setup stores the preferred Pinia API for teams already familiar with composables. The only semantic change: everything returned from the setup store's return statement becomes part of the public store interface.


// src/stores/useAuthStore.ts
// Pinia Setup Store, composable syntax with full DevTools integration
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  // State as refs, shown in Vue DevTools
  const user = ref<{ id: number; name: string; email: string } | null>(null)
  const token = ref<string | null>(null)
  const loading = ref(false)

  // Getters as computed, cached and reactive
  const isAuthenticated = computed(() => token.value !== null)
  const displayName = computed(() => user.value?.name ?? 'Guest')

  // Actions, can be async, tracked in DevTools
  async function login(email: string, password: string) {
    loading.value = true
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
        headers: { 'Content-Type': 'application/json' },
      })
      const data = await response.json()
      token.value = data.token
      user.value = data.user
    } finally {
      loading.value = false
    }
  }

  function logout() {
    user.value = null
    token.value = null
  }

  return { user, token, loading, isAuthenticated, displayName, login, logout }
})

5. DevTools integration: why Pinia state is visible

Vue DevTools show every active Pinia store with its complete state, all getters, and an action log in real time. That lets you immediately see, when debugging, what the current auth state is, which products are in the cart, and which action ran last, without console logs or breakpoints. Time-travel debugging lets you jump back to an earlier state and observe the application again from that point.

Composable stores are invisible to DevTools. A useNotifications composable with module-scope state does not show up in the DevTools panel. That makes debugging harder, because you have to manually add console.log statements to inspect the state. For small, well-understood state units that is acceptable. For critical application state, authentication, shopping cart, checkout steps, the DevTools visibility of Pinia is a decisive advantage that reduces debugging time in production.

The Pinia plugin system further reinforces the DevTools advantage: the pinia-plugin-persistedstate plugin automatically persists selected store properties to local storage, with full DevTools integration. A composable store would have to implement persistence manually, without getting the DevTools synchronization. For applications with complex persisted state, Pinia is therefore clearly preferable.

6. SSR and hydration: Pinia vs. composable store in server rendering

In Nuxt 3 applications or other SSR setups, the choice between a composable store and Pinia is no longer a style question, it is a correctness question. Server processes handle multiple requests at the same time. Module-scope state, meaning the state in a composable store, is shared across requests. A user A who logs in could theoretically leak state into user B's request. That is a critical security bug, not a performance problem.

Pinia solves SSR through its architecture: Nuxt creates a new Pinia instance for every request via createPinia(). All stores in that request use this instance and are isolated from one another. After rendering, Pinia serializes the state and sends it to the client. On the client, Pinia hydrates the stores from the serialized state, so no repeat API call is needed. This hydration protocol is a mature feature that would have to be implemented manually for composable stores.

7. The decision matrix: composable or Pinia?

The decision between a composable store and Pinia can be reduced to five questions. If any one of them is answered yes, Pinia is the better choice: is the state shared by unconnected components on different routes? Should the state be visible in Vue DevTools? Is SSR being used? Is persistence (local storage, cookie) a requirement? Do actions need to be logged in DevTools?

If all five questions are answered no, a simple composable or a composable store is sufficient. That typically applies to: UI state within a single page or feature, loading indicators and error state for specific components, temporary form state that does not need to be persisted, and state that is only shared within a component tree and can be distributed with provide/inject.

Criterion Composable Store Pinia Store Recommendation
SSR / Nuxt Request contamination possible Request isolated Pinia
DevTools visibility Invisible Fully integrated Pinia
Boilerplate Minimal defineStore + ID Composable
Persistence Manual Automatic via plugin Pinia
Testing Simple createPinia() needed in tests Both work well

8. Store composition: using Pinia stores inside other stores

One of the most powerful features of Pinia is store composition: a store can call other stores with useOtherStore() inside its actions or getters. That enables granular, thematically clear stores, useCartStore and useProductStore, that collaborate inside a useCheckoutStore, without cramming all the logic into one monolithic store. This composition capability is not fundamentally missing from composable stores, but it is harder to coordinate cleanly without the Pinia infrastructure.

The convention for Pinia store composition: never import and instantiate another store at module level, always call it lazily inside an action or a getter. That avoids circular dependencies and ensures Pinia is fully initialized before the dependent store is called. In setup stores, composition is even more natural, you call useAuthStore() directly inside the setup function and get access to all state properties of the auth store as reactive refs.


// src/stores/useCheckoutStore.ts
// Store composition: checkout depends on cart and auth stores
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useCartStore } from './useCartStore'
import { useAuthStore } from './useAuthStore'

export const useCheckoutStore = defineStore('checkout', () => {
  const cart = useCartStore()    // compose other Pinia stores
  const auth = useAuthStore()

  const step = ref<'address' | 'payment' | 'confirm'>('address')
  const processing = ref(false)

  // Getter using composed store state
  const canCheckout = computed(() =>
    auth.isAuthenticated && cart.items.length > 0 && !processing.value
  )

  async function submitOrder() {
    if (!canCheckout.value) return
    processing.value = true
    try {
      await fetch('/api/orders', {
        method: 'POST',
        body: JSON.stringify({ items: cart.items, userId: auth.user?.id }),
        headers: { 'Content-Type': 'application/json' },
      })
      cart.clear()   // action from composed store
      step.value = 'confirm'
    } finally {
      processing.value = false
    }
  }

  return { step, processing, canCheckout, submitOrder }
})

9. Direct comparison: composable store vs. Pinia store

In daily practice, Pinia stores and composable stores do not compete, they complement each other. The decision is context-dependent and should be made per state unit, not per project. A Vue 3 project can use Pinia for global application state and simple composables for component-local state at the same time, without contradiction.

The decisive practical difference for medium and large teams: Pinia stores are immediately documented through their ID and their structure. A new team member sees at once, in the DevTools, which stores exist and what is inside them. A composable store can only be discovered by reading the source code. For teams with more than three or four developers, the explicitness of Pinia is usually worth the minimal extra effort.

Mironsoft

Vue 3 architecture, state management and Pinia setup for complex applications

Need a state management architecture for your Vue 3 project?

We analyze your existing state structure, identify misused composable stores, and migrate critical state to Pinia, with DevTools integration, SSR safety, and plugin setup.

State audit

Analyze the existing state structure and identify critical composable store risks in SSR

Pinia migration

Migrate Vuex or composable stores to Pinia setup stores with full test coverage

Plugin setup

Set up and document persistence, logging and reset via Pinia plugins

10. Summary

The choice between Pinia and a composable store is not a dogmatic decision, but a trade-off based on concrete requirements. Composable stores are ideal for state that does not need to be SSR-safe, does not need DevTools visibility, and is only shared within clearly bounded feature boundaries. Pinia is the right choice for critical application state, SSR environments, teams with DevTools-based workflows, and applications that need persistence or plugin infrastructure.

Setup stores in Pinia make migrating composable stores trivial: the syntax is nearly identical, only the wrapper defineStore('id', () => { ... }) is added. Teams already familiar with Vue composables can carry that familiarity straight over to Pinia setup stores. Store composition enables granular, testable stores instead of a monolith. The result is a state management approach that grows with application complexity without sacrificing maintainability.

Composable Stores vs. Pinia: the essentials at a glance

Composable Store

Module-scope state without a library. Suited for non-SSR contexts, small teams, state without a DevTools requirement. Do not use with SSR.

Pinia Store

Request-isolated, DevTools-visible, plugin-capable. Mandatory for SSR, global state, and teams with DevTools-based debugging needs.

Setup Stores

Composable syntax with Pinia infrastructure. The best migration path from composable stores, nearly identical syntax, full DevTools integration.

Store Composition

Pinia stores can call other stores. Granular, thematic stores instead of a monolith. Use useCartStore inside useCheckoutStore.

11. FAQ: Composable Stores vs. Pinia Stores

1Composable store vs. Pinia: the core difference?
Composable store: module-scope state, invisible to DevTools, not SSR-safe. Pinia: request-isolated, DevTools-visible, plugin-capable.
2Is Pinia always better?
No. For feature-local state without an SSR or DevTools requirement, a composable store is lighter-weight and has less boilerplate.
3Why are composable stores problematic in SSR?
Module scope is shared across requests. State from user A can leak to user B. Pinia is request-isolated through createPinia() per request.
4What are Pinia setup stores?
Composable syntax as the store definition: defineStore('id', () => { ... }). Full Pinia infrastructure with familiar ref/computed syntax.
5Store composition in Pinia?
Call useOtherStore() inside actions and getters. Never instantiate at module level, to avoid circular dependencies.
6Pinia persistence?
pinia-plugin-persistedstate serializes state to local storage with DevTools integration. No manual code needed.
7Testing Pinia stores?
createPinia() + setActivePinia() per test. A fresh instance for every test. Set state, call actions, check getters.
8Migrate Vuex to Pinia?
Yes, Pinia is the official Vuex successor for Vue 3. Lighter, more type-safe, better integrated. Migrate iteratively, per store.
9$reset() in setup stores?
Only built in for options stores. In setup stores, implement your own reset action or use the $reset plugin.
10Mixing Pinia and composable stores?
Yes, that is the recommended pattern. Pinia for global state, composable stores for feature-local state without SSR. Coexist without problems.