Vue 3 Composables vs Pinia: I Was Using the Wrong One for a Year

Composables and Pinia solve different problems — but they look similar enough that most developers over-use one and under-use the other. Here’s the exact decision framework I wish someone had given me on day one.


For about a year after Vue 3 became stable, I used Pinia for everything that needed shared state, and composables for everything local. That felt like a reasonable division. Pinia is the “official state management” solution. Composables are for reusable logic. Obvious, right?

It wasn’t obvious. It was wrong. Or at least, it was dramatically over-simplified.

I was adding Pinia stores for things that should have been composable singletons. I was writing composables with module-level state when Pinia would have given me devtools, persistence plugins, and testability for free. I was choosing based on the name — “state management” — rather than the actual requirements.

The confusion is understandable. Both tools use Vue’s reactivity primitives. Both can hold shared reactive state. Both can be used in components. The surface area overlaps enough that the wrong choice isn’t immediately obvious — it only becomes clear later, when the codebase has grown and the trade-offs compound.

This post gives you the framework for making the right call the first time.


What Each Tool Actually Is

Composables: Functions That Use Reactivity

A composable is a function. It uses Vue’s Composition API internally — ref, reactive, computed, watch, onMounted — and returns whatever the consumer needs. That’s the entire definition.

The critical thing about composables: where the reactive state is declared determines its scope.

// ── Per-instance state ──────────────────────────────────────────────
// State declared INSIDE the function = new instance per call
// Every component that calls useCounter() gets its own independent count

export function useCounter(initial = 0) {
  const count   = ref(initial)           // ← created fresh each call
  const doubled = computed(() => count.value * 2)

  return { count, doubled, increment: () => count.value++ }
}

// ComponentA calls useCounter() → its own count: 0
// ComponentB calls useCounter() → its own count: 0 (independent)
// ── Singleton state ─────────────────────────────────────────────────
// State declared OUTSIDE the function = shared across ALL callers
// Every component that calls useTheme() gets the SAME theme ref

const theme = ref<'light' | 'dark'>('light')   // ← created once, module-level

export function useTheme() {
  function toggle() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }
  return { theme: readonly(theme), toggle }
}

// ComponentA calls useTheme() → theme ref: 'light'
// ComponentB calls useTheme() → same theme ref: 'light'
// ComponentA toggles → ComponentB sees 'dark' automatically

This single distinction — state inside vs outside the function — is the most important thing to understand about composables. It’s also what makes composables capable of replacing simple Pinia stores for straightforward shared state.

Pinia: A Structured Store System

Pinia is an official, dedicated state management library. It provides:

  • A consistent, structured API for defining stores
  • Vue Devtools integration — inspect and time-travel state changes
  • Hot Module Replacement — state survives code changes in development
  • Plugin ecosystem — persistence, reset, undo/redo, encryption
  • SSR support — proper hydration and server/client state isolation
  • Testability primitivessetActivePinia, createTestingPinia
// app/stores/useCartStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

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

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

  const discount = computed(() => {
    if (!coupon.value) return 0
    return subtotal.value * 0.1
  })

  const total = computed(() => subtotal.value - discount.value)

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

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

  function applyCoupon(code: string) {
    coupon.value = code
  }

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

  return { items, coupon, subtotal, discount, total, addItem, removeItem, applyCoupon, clear }
})

Where the Confusion Comes From

Both tools can hold shared singleton state. This simple fact is the root of all confusion:

// These two implementations are functionally identical
// for a simple theme switcher:

// ── Option A: Composable singleton ──────────────────────────────────
const theme = ref<'light' | 'dark'>('light')
export function useTheme() {
  return { theme: readonly(theme), toggle: () => {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }}
}

// ── Option B: Pinia store ────────────────────────────────────────────
export const useThemeStore = defineStore('theme', () => {
  const theme = ref<'light' | 'dark'>('light')
  function toggle() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }
  return { theme, toggle }
})

Both work. Both are reactive. Both share state across components. So why does the choice matter?

Because they have completely different operational characteristics — and those differences only become apparent once your application grows.


The Real Differences That Matter in Practice

1. Devtools Visibility

Pinia stores appear in Vue Devtools with their full state tree, mutation history, and time-travel debugging. Composable singletons are invisible to Devtools.

For debugging a cart, order flow, or user session — where you need to see exactly what the state was at any point in time — Pinia’s Devtools integration is enormously valuable. For a theme preference that’s either ‘light’ or ‘dark’, it adds no value.

2. Plugin Support

Pinia has a first-class plugin system. The most commonly used plugins:

// Persist state to localStorage automatically
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// Then on any store:
export const useCartStore = defineStore('cart', () => {
  // ...
}, {
  persist: {
    key: 'cart',
    storage: localStorage,
    paths: ['items'],  // only persist items, not derived state
  },
})

Replicating persistence in a composable singleton requires manual watchlocalStorage.setItem + initial read from localStorage.getItem. Doable, but more code, less standardised, and not shared across stores automatically.

3. SSR and Hydration

Pinia handles server-side rendering properly out of the box. Each request gets its own Pinia instance, preventing state from leaking between server-rendered requests. Composable singletons with module-level state are shared across requests on the server — a critical security issue.

// ✗ DANGEROUS in SSR — module-level state is shared between all requests
const user = ref<User | null>(null)  // all server requests share this!

export function useCurrentUser() {
  return { user: readonly(user) }
}

// ✓ Safe in SSR — Pinia creates a new store instance per request
export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null)
  return { user }
})

If you’re using Nuxt or any SSR setup, use Pinia for any state that is user-specific or request-scoped. Composable singletons are only safe for SSR when the state is truly global and identical for all users (e.g., a list of countries, feature flags).

4. Testing

Both can be tested, but Pinia provides specific testing primitives:

// Testing a Pinia store
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '@/stores/cart'

beforeEach(() => {
  setActivePinia(createPinia())
})

test('adds item to cart', () => {
  const cart = useCartStore()
  cart.addItem({ id: 1, name: 'Widget', price: 10 }, 2)

  expect(cart.items).toHaveLength(1)
  expect(cart.subtotal).toBe(20)
})
// Testing components that use Pinia — stub the entire store
import { createTestingPinia } from '@pinia/testing'

const wrapper = mount(CartSummary, {
  global: {
    plugins: [
      createTestingPinia({
        initialState: {
          cart: { items: [{ id: 1, name: 'Widget', price: 10, qty: 2 }] },
        },
      }),
    ],
  },
})

Composable singletons are harder to test in isolation because the module-level state persists between tests. You need manual reset functions or module re-imports to get a clean state.

// Composable singleton — needs explicit reset between tests
const theme = ref<'light' | 'dark'>('light')

export function useTheme() {
  function reset() { theme.value = 'light' }  // add for testing
  return { theme: readonly(theme), toggle, reset }
}

// In tests:
afterEach(() => {
  useTheme().reset()
})

The Decision Framework

Is this state used in more than one component?
├── No → Local component state (ref/reactive in script setup)
└── Yes
    │
    Is the state purely derived from other state or props?
    ├── Yes → Computed property or a composable with no side effects
    └── No (it's genuinely shared, mutable state)
        │
        Does this state need any of the following?
        ├── Vue Devtools inspection          → Pinia
        ├── Persistence (localStorage etc.) → Pinia + plugin
        ├── SSR / Nuxt usage               → Pinia
        ├── Time-travel debugging           → Pinia
        ├── Testing with stub/initial state → Pinia
        └── None of the above
            │
            Is the state complex? (multiple related fields,
            non-trivial mutations, business logic)
            ├── Yes  → Pinia (the structure pays for itself)
            └── No (simple, 1-3 values, minimal logic)
                └── Composable singleton

In plain language:

  • Pinia for application domain state: cart, user session, notifications, permissions, anything that has business logic attached to it
  • Pinia for anything that needs persistence, Devtools visibility, or SSR safety
  • Composable singleton for cross-cutting UI state: theme, locale, modal manager, toast queue — simple shared values with minimal logic
  • Per-instance composable for reusable logic that each component needs independently: useFetch, useDebounce, useIntersectionObserver, form validation

When I Was Using the Wrong One

Here’s the actual mistake I was making for a year — illustrated with before and after.

Mistake 1: Pinia for Simple UI State

// ✗ Pinia overkill for a two-value theme switcher
export const useThemeStore = defineStore('theme', () => {
  const mode = ref<'light' | 'dark'>('light')
  function toggle() { mode.value = mode.value === 'light' ? 'dark' : 'light' }
  function setMode(m: 'light' | 'dark') { mode.value = m }
  return { mode, toggle, setMode }
})

This works, but it adds a store definition, a defineStore call, and registers a named store in Pinia — for a value that’s either ‘light’ or ‘dark’. There’s nothing to inspect in Devtools. There’s no business logic. It doesn’t need persistence beyond localStorage (which we can handle directly). It doesn’t need SSR isolation.

// ✓ Composable singleton — right tool for this job
import { ref, readonly } from 'vue'

const stored = localStorage.getItem('theme') as 'light' | 'dark' | null
const mode   = ref<'light' | 'dark'>(stored ?? 'light')

watch(mode, (val) => localStorage.setItem('theme', val))

export function useTheme() {
  function toggle()                  { mode.value = mode.value === 'light' ? 'dark' : 'light' }
  function setMode(m: 'light' | 'dark') { mode.value = m }
  return { mode: readonly(mode), toggle, setMode }
}

Mistake 2: Composable Singleton for Domain State

// ✗ Composable singleton for something that clearly needs Pinia
// Module-level state for the user's shopping cart

const items  = ref<CartItem[]>([])
const coupon = ref<string | null>(null)

export function useCart() {
  function addItem(product: Product) {
    // ... complex logic
  }
  // No Devtools. No persistence plugin. Hard to test. Leaks in SSR.
  return { items: readonly(items), coupon: readonly(coupon), addItem }
}

The cart has business logic (discount calculation, coupon validation, quantity management), needs persistence, benefits from Devtools inspection when debugging checkout issues, and needs to be properly isolated in SSR. Every one of those points is an argument for Pinia.

// ✓ Pinia with persistence — correct tool for the cart
export const useCartStore = defineStore('cart', () => {
  const items  = ref<CartItem[]>([])
  const coupon = ref<string | null>(null)
  // ... full implementation from earlier
}, {
  persist: { paths: ['items', 'coupon'] },
})

The Patterns That Belong in Composables

These are genuinely composable concerns — not stores:

Per-Component Async Data Loading

// Each component gets its own independent fetch state
export function useFetch<T>(url: Ref<string> | string) {
  const data    = ref<T | null>(null)
  const error   = ref<string | null>(null)
  const loading = ref(false)

  watchEffect(async (onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    const endpoint = typeof url === 'string' ? url : url.value
    loading.value  = true
    error.value    = null

    try {
      const res  = await fetch(endpoint, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      data.value = await res.json() as T
    } catch (err) {
      if ((err as Error).name !== 'AbortError') error.value = (err as Error).message
    } finally {
      loading.value = false
    }
  })

  return { data, error, loading }
}

Reusable UI Behaviour

// Form validation — each form component gets its own validation state
export function useFormValidation<T extends Record<string, unknown>>(
  initialValues: T,
  rules: ValidationRules<T>
) {
  const values  = reactive({ ...initialValues })
  const errors  = reactive({} as Record<keyof T, string>)
  const touched = reactive({} as Record<keyof T, boolean>)

  function validate(): boolean {
    let isValid = true
    for (const field in rules) {
      const error = rules[field](values[field])
      if (error) {
        errors[field] = error
        isValid = false
      } else {
        delete errors[field]
      }
    }
    return isValid
  }

  return { values, errors, touched, validate }
}

Browser API Wrappers

// Window scroll — single subscription, shared via singleton pattern
const scrollY = ref(0)

if (typeof window !== 'undefined') {
  window.addEventListener('scroll', () => {
    scrollY.value = window.scrollY
  }, { passive: true })
}

export function useScrollPosition() {
  return { scrollY: readonly(scrollY) }
}

The Patterns That Belong in Pinia

User Authentication and Session

export const useAuthStore = defineStore('auth', () => {
  const user    = ref<User | null>(null)
  const token   = ref<string | null>(null)
  const loading = ref(false)

  const isAuthenticated = computed(() => !!user.value)
  const isAdmin         = computed(() => user.value?.role === 'admin')

  async function login(credentials: LoginCredentials) {
    loading.value = true
    try {
      const res    = await api.post<{ user: User; token: string }>('/auth/login', credentials)
      user.value   = res.data.user
      token.value  = res.data.token
    } finally {
      loading.value = false
    }
  }

  async function logout() {
    await api.post('/auth/logout')
    user.value  = null
    token.value = null
  }

  async function fetchMe() {
    if (!token.value) return
    const res  = await api.get<User>('/auth/me')
    user.value = res.data
  }

  return { user, token, loading, isAuthenticated, isAdmin, login, logout, fetchMe }
}, {
  persist: { paths: ['token'] },  // persist token only
})

Notifications System

export const useNotificationStore = defineStore('notifications', () => {
  const queue = ref<Notification[]>([])

  function push(notification: Omit<Notification, 'id'>) {
    const id = crypto.randomUUID()
    queue.value.push({ ...notification, id })

    // Auto-dismiss after duration
    if (notification.duration !== Infinity) {
      setTimeout(() => dismiss(id), notification.duration ?? 4000)
    }

    return id
  }

  function dismiss(id: string) {
    queue.value = queue.value.filter(n => n.id !== id)
  }

  // Convenience methods
  const success = (message: string) => push({ type: 'success', message })
  const error   = (message: string) => push({ type: 'error',   message, duration: Infinity })
  const info    = (message: string) => push({ type: 'info',    message })

  return { queue: readonly(queue), push, dismiss, success, error, info }
})

Side-by-Side: The Same Feature Built Both Ways

A global “who’s online” user presence indicator — to see which approach fits better.

// ── Composable approach ───────────────────────────────────────────────
// Works fine for this simple case

const onlineUsers = ref<{ id: number; name: string }[]>([])

let channel: ReturnType<typeof Echo.join> | null = null

export function useOnlinePresence(roomId: number) {
  if (!channel) {
    channel = Echo.join(`rooms.${roomId}`)
      .here((users) => { onlineUsers.value = users })
      .joining((user) => { onlineUsers.value.push(user) })
      .leaving((user) => {
        onlineUsers.value = onlineUsers.value.filter(u => u.id !== user.id)
      })
  }

  return { onlineUsers: readonly(onlineUsers) }
}
// ── Pinia approach ────────────────────────────────────────────────────
// Better once you need Devtools, testing, or multiple rooms

export const usePresenceStore = defineStore('presence', () => {
  const rooms = ref<Record<number, { id: number; name: string }[]>>({})

  function setRoom(roomId: number, users: { id: number; name: string }[]) {
    rooms.value[roomId] = users
  }

  function addUser(roomId: number, user: { id: number; name: string }) {
    if (!rooms.value[roomId]) rooms.value[roomId] = []
    rooms.value[roomId].push(user)
  }

  function removeUser(roomId: number, userId: number) {
    if (!rooms.value[roomId]) return
    rooms.value[roomId] = rooms.value[roomId].filter(u => u.id !== userId)
  }

  function getUsersInRoom(roomId: number) {
    return computed(() => rooms.value[roomId] ?? [])
  }

  return { rooms, setRoom, addUser, removeUser, getUsersInRoom }
})

For a single, simple room: the composable is fine. Once you need multiple rooms, Devtools to debug who’s in which room, or the ability to mock presence state in tests — Pinia is clearly better.


The Rule That Resolves 90% of Cases

If you’re still uncertain after all of this, apply this single test:

Would you want to see this state in Vue Devtools?

  • Yes → Use Pinia. You want it there because it’s important domain state, you’ll need to debug it, or it has enough complexity to warrant inspection.
  • No → Use a composable. It’s either too simple to warrant Devtools overhead, it’s per-instance state that doesn’t make sense globally, or it’s purely UI behaviour with no meaningful state to inspect.

Theme preference: no. Cart contents: yes. Form validation errors: no (per-form). User session: yes. Scroll position: no. Notification queue: yes.

This single question predicts the right answer almost every time.


Final Thoughts

The reason most developers get this wrong isn’t a lack of understanding — it’s that the tools overlap just enough to make the wrong choice work. A composable singleton that should be a Pinia store will function correctly for years, right up until you need to debug a production issue and realise the state is invisible to Devtools. A Pinia store that should be a composable will work fine indefinitely, but it’ll add noise to your store list and overhead to your tests.

The framework in this post isn’t a set of rigid rules. It’s a set of questions that surface the requirements your storage choice should be optimised for. Ask them before you write the first line of code, and you’ll make the right call almost every time.

Use Pinia for domain state with business logic, persistence needs, and SSR requirements. Use composables for reusable behaviour, per-instance state, and simple shared UI values. And when in doubt: would you want to see it in Devtools? That’s your answer.

Leave a Reply

Your email address will not be published. Required fields are marked *