Pinia vs Composables: Choosing Your State Strategy
Every Vue 3 developer hits this moment sooner or later. You need to share some state between components and you ask yourself: "Should I create a Pinia store or just a composable?"
The honest answer is: it depends. But not in a vague, unhelpful way — there are clear signals that point you in one direction or the other. Let's walk through them, from the simplest case to the most complex.
Level 1: Local state — you need neither
Before reaching for anything, ask yourself: does this state actually need to be shared?
<script setup lang="ts">
const isOpen = ref(false);
const searchQuery = ref('');
</script>
A modal toggle, a form input, a local counter — these live and die with the component. No store, no composable. Just ref and move on.
This sounds obvious, but you'd be surprised how often state gets lifted into a store "just in case". Resist that urge. Keep state as local as possible, and only lift it when you have a real reason.
Level 2: Shared feature state — composable wins
Now things get interesting. You have two or three components that need the same piece of state. Maybe a notification system, a sidebar toggle, or a shopping cart counter in the header.
A composable with module-level state handles this perfectly:
// composables/useCart.ts
import { ref, computed, readonly } from 'vue';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
const items = ref<CartItem[]>([]);
export function useCart() {
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const count = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
);
function addItem(product: Omit<CartItem, 'quantity'>) {
const existing = items.value.find((i) => i.id === product.id);
if (existing) {
existing.quantity++;
} else {
items.value.push({ ...product, quantity: 1 });
}
}
function removeItem(id: string) {
items.value = items.value.filter((i) => i.id !== id);
}
function clear() {
items.value = [];
}
return {
items: readonly(items),
total,
count,
addItem,
removeItem,
clear,
};
}
Any component calls useCart() and gets the same shared state. The header shows count, the cart page shows items, the product card calls addItem. They all work off the same ref because ES modules are singletons.
No setup required. No plugin to install. No storeToRefs. Just import and use.
Level 3: App-wide state with DevTools — Pinia enters
As your app grows, you start needing things that a plain composable can't give you:
- DevTools integration — inspecting state, tracking mutations, time-travel debugging
- SSR hydration — Pinia handles server/client state transfer automatically
- Hot Module Replacement — change store logic without losing state during development
- Plugin ecosystem — persistence, logging, sync with external systems
This is where Pinia shines:
// stores/auth.ts
import { defineStore } from 'pinia';
interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(null);
const isAuthenticated = computed(() => !!token.value);
const isAdmin = computed(() => user.value?.role === 'admin');
async function login(email: string, password: string) {
const response = await $fetch('/api/auth/login', {
method: 'POST',
body: { email, password },
});
token.value = response.token;
user.value = response.user;
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' });
token.value = null;
user.value = null;
navigateTo('/login');
}
async function refreshUser() {
if (!token.value) return;
user.value = await $fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token.value}` },
});
}
return { user, token, isAuthenticated, isAdmin, login, logout, refreshUser };
});
Authentication is the textbook example. It's used everywhere — navigation guards, API interceptors, layout components, individual pages. It needs to survive SSR. You want to see it in DevTools. Pinia is the right call here.
Level 4: Complex interdependent state — Pinia + composables together
Here's what most articles miss: you don't have to choose one or the other. The best architecture often uses both.
Pinia for the core app state (auth, user preferences, feature flags). Composables for feature-specific logic that consumes that state:
// composables/usePermissions.ts
export function usePermissions() {
const authStore = useAuthStore();
const canEdit = computed(() =>
authStore.isAuthenticated && authStore.isAdmin
);
const canView = computed(() => authStore.isAuthenticated);
function requireAuth() {
if (!authStore.isAuthenticated) {
navigateTo('/login');
return false;
}
return true;
}
return { canEdit, canView, requireAuth };
}
// composables/useDashboard.ts
export function useDashboard() {
const authStore = useAuthStore();
const { canEdit } = usePermissions();
const { notify } = useNotifications();
const { data: stats, isLoading } = useAsyncData(
() => $fetch('/api/dashboard/stats', {
headers: { Authorization: `Bearer ${authStore.token}` },
})
);
async function exportReport() {
if (!canEdit.value) {
notify('You need admin access to export reports', 'error');
return;
}
const blob = await $fetch('/api/dashboard/export', {
headers: { Authorization: `Bearer ${authStore.token}` },
});
// trigger download...
notify('Report exported successfully', 'success');
}
return { stats, isLoading, exportReport };
}
See the layers? Pinia manages what the user is. Composables manage what the user can do and what they see. Each layer has a clear responsibility.
The trap: putting everything in Pinia
A common mistake is creating a Pinia store for every piece of shared state. You end up with useModalStore, useToastStore, useSidebarStore, useSearchStore... dozens of stores for things that don't need the overhead.
The problem isn't just boilerplate. It's that Pinia stores are global by design. When everything is global, you lose the ability to reason about scope. Which component depends on which store? What happens if you remove a store? With composables, the import graph tells you everything.
Ask yourself: "Would I lose sleep if this state wasn't visible in DevTools?" If the answer is no, a composable is probably enough.
The trap: avoiding Pinia entirely
The opposite mistake is just as common. You build a whole app with composable singletons and then realize:
- You can't debug state in production because there's no DevTools panel
- SSR is broken because module-level
refs are shared across requests on the server - You need to persist state to localStorage and end up writing a custom plugin that Pinia already provides
Composables are great — until your app reaches a certain scale. Then you want the infrastructure that Pinia gives you for free.
Quick decision framework
When you're staring at a new piece of state, run through these questions:
1. Is it used in just one component?
Keep it local. ref in <script setup>. Done.
2. Is it shared across a few components in one feature? Composable with module-level state. Simple, no dependencies.
3. Do you need DevTools, SSR support, or persistence? Pinia. The tooling pays for itself.
4. Is it core app state used by many features? Pinia. Auth, user settings, feature flags — these are store territory.
5. Is it feature logic that reads from stores? Composable that imports from Pinia stores. Best of both worlds.
The comparison table
| Criteria | Composable | Pinia |
|---|---|---|
| Setup required | None — just a .ts file | Install plugin, create store |
| Shared state | Module-level ref (singleton) | Built-in, designed for it |
| DevTools support | No | Yes — inspect, edit, time-travel |
| SSR safety | Risky — module singletons leak across requests | Safe — state is per-request |
| HMR (Hot Reload) | State resets on file change | State preserved across edits |
| Persistence | Manual (write your own localStorage sync) | Plugin available (pinia-plugin-persistedstate) |
| TypeScript | Full support | Full support |
| Testing | Import and call — straightforward | setActivePinia() setup needed |
| Boilerplate | Minimal | Low, but more than composable |
| Plugins / middleware | Not built-in | Built-in plugin system |
| Best for | Feature-scoped state, UI logic, browser APIs | App-wide state, auth, settings, anything needing DevTools |
| Scales to | Small-medium complexity | Any complexity |
| Can use the other? | Yes — composables can import stores | Yes — stores can use composables |
TL;DR: Start with local state. When you need to share, reach for a composable. When you need DevTools, SSR, or persistence, reach for Pinia. In real apps, use both — Pinia for the core, composables for the features. The best state strategy is the one that matches the scope of your problem.