Pinia vs composables is a question every Vue 3 project hits once two components need the same data. A composable can already share state: move a ref outside the function and every caller sees the same value. So why add a store? Because that shortcut has three weak spots: server-side rendering, devtools and conventions. This post gives a short rule for choosing, shows where module-level state breaks, and refactors a shared composable into a Pinia setup store step by step.
If you haven't written composables before, start with the Vue composables guide. This post assumes you know what useSomething() returns and why.
Ask one question: does this state belong to one caller, or to the whole app?
| The state is... | Use |
|---|---|
| Local to each caller (a toggle, a form, a pagination cursor) | A composable. Every call gets its own refs. |
| Shared, client-only, small, and you are the only one working on it | A composable with module-level state is fine. |
| Shared, and the app renders on the server (Nuxt, Quasar SSR) | Pinia, or Nuxt's useState for simple values. |
| Shared, and you want to inspect or time-travel it in Vue DevTools | Pinia. |
| Shared across features that different people work on | Pinia, for the conventions. |
Composables and Pinia are not rivals. A Pinia setup store is written almost exactly like a composable, and a store can call composables inside it. The choice is about where the state lives, not about the style of the code.
A composable that creates its refs inside the function gives each caller a fresh copy. To share, move the state to the top of the module:
// composables/useCart.ts
import { computed, ref } from 'vue'
export interface CartItem {
id: string
name: string
price: number
qty: number
}
const items = ref<CartItem[]>([])
export function useCart() {
const count = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))
const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.qty, 0))
function add(product: Omit<CartItem, 'qty'>) {
const existing = items.value.find(item => item.id === product.id)
if (existing)
existing.qty++
else
items.value.push({ ...product, qty: 1 })
}
function remove(id: string) {
items.value = items.value.filter(item => item.id !== id)
}
return { items, count, total, add, remove }
}The header badge and the cart page both call useCart() and see the same items. In a client-only app this works, and the Vue docs describe this exact pattern (a module-level reactive() object) as simple state management.
The same Vue docs page lists what a hand-rolled store doesn't give you, and why Pinia exists.
The items ref above is created once, when the module loads. In the browser, that happens once per visit, so each user has their own. On a server, modules load once when the server starts and are reused for every request. The Vue SSR guide calls the result cross-request state pollution: if one request writes user-specific data into module state, the next user's request can read it.
For a cart, that means one visitor could see another visitor's cart in the server-rendered HTML. The Vue docs' fix is to create the state per request and provide it to the app, and they point out that Pinia is designed with this in mind. In a Nuxt app, or a Quasar app in SSR mode, this alone decides it.
The Vue docs also list devtools integration (timeline, in-component inspection, time-travel debugging), hot module replacement and stronger conventions for teams. With module-level refs you get none of that: any file that imports useCart can write to items directly, and there is no single place to see who changed it.
The docs suggest a partial fix, exposing methods with names that say what they do, and the composable above already does that. It is still a convention nobody enforces.
Pinia has two ways to define a store. The setup store looks like the composable you already have, so the refactor is mostly a move:
// stores/cart.ts
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { CartItem } from '../composables/useCart'
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const count = computed(() => items.value.reduce((sum, item) => sum + item.qty, 0))
const total = computed(() => items.value.reduce((sum, item) => sum + item.price * item.qty, 0))
function add(product: Omit<CartItem, 'qty'>) {
const existing = items.value.find(item => item.id === product.id)
if (existing)
existing.qty++
else
items.value.push({ ...product, qty: 1 })
}
function remove(id: string) {
items.value = items.value.filter(item => item.id !== id)
}
return { items, count, total, add, remove }
})What changed:
'cart', which is how it shows up in devtools.refs become state, computeds become getters and functions become actions.One rule from the Pinia docs matters here: return every piece of state. Pinia only treats what you return as state. A ref you keep private, or return as readonly, breaks SSR, devtools and plugins. If something must stay private, it doesn't belong in the store's state.
Now use it in a component:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'
const cart = useCartStore()
const { items, total } = storeToRefs(cart)
const { remove } = cart
</script>
<template>
<q-list separator>
<q-item v-for="item in items" :key="item.id">
<q-item-section>{{ item.name }} × {{ item.qty }}</q-item-section>
<q-item-section side>
<q-btn flat round icon="delete" aria-label="Remove" @click="remove(item.id)" />
</q-item-section>
</q-item>
</q-list>
<div class="text-subtitle1 q-pa-md">Total: {{ total.toFixed(2) }}</div>
</template>Destructuring a store directly loses reactivity, the same way destructuring a reactive() object does. storeToRefs() turns state and getters into refs you can destructure. Actions are plain functions, so you can take them straight from the store.
The callers barely change: useCart() becomes useCartStore() plus one storeToRefs line. That is why starting with a composable and moving to a store later is a cheap path, not a mistake.
A setup store can call composables, set up watchers and read app-level values with inject(). For example, a store can use a useLocalStorage-style composable to persist the cart. The Pinia docs add a caution: composables get more complex under SSR, since anything that touches window or localStorage must not run on the server.
The docs also say not to return things that aren't the store's own, like the router or injected values. Components can get those themselves with useRoute() or inject().
Outside components (route guards, other stores, Nuxt middleware), call useCartStore() only after Pinia is installed, because it injects the active Pinia instance. On the server, pass the pinia instance to it explicitly, so it can't fall back to a shared one.
In Nuxt, install the module with npx nuxi@latest module add pinia, or install pinia and @pinia/nuxt and add '@pinia/nuxt' to modules. The module auto-imports defineStore, storeToRefs and usePinia, plus every store in app/stores in Nuxt 4 (nested folders need the storesDirs option). Where that folder sits among the rest is covered in structuring Quasar projects.
For one or two shared values, a store can be more than you need. Nuxt's useState creates SSR-safe shared state from a key:
// composables/useSidebar.ts
export const useSidebarOpen = () => useState('sidebar-open', () => true)The value is serialized into the page payload, so it must be JSON-friendly: no class instances, functions or symbols. Reach for Pinia when the state comes with actions and getters, or when you want it in devtools.
Is Pinia overkill for a small app? For a client-only app with a little shared state, a composable with module state is fine. Once you add SSR, Pinia (or useState) stops being optional.
Should I use option stores or setup stores? Either works. Setup stores read like composables and let you call composables inside; option stores give you $reset() for free, which a setup store has to write itself.
Can a composable use a store? Yes. A useCheckout() composable that calls useCartStore() inside is a good way to keep page logic out of the store.
Does Pinia replace provide/inject? For app-wide state, mostly. For state scoped to one subtree, such as a wizard's steps, provide/inject or a local composable is still the better fit.
State management is one layer of an admin app; the screens built on top of it take longer. The QuasarUI Templates are four complete Quasar admin dashboards with light and dark mode, with charts, reports and admin pages ready for your stores to feed.