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 primitives —
setActivePinia,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 watch → localStorage.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.
