
Using Pinia vs Basic State Management: When Vue's built-in reactivity is enough and when Pinia earns its place in your project.
Reza Baar
May 20, 2026
Not every Vue app needs a state management library. Vue's reactivity system is powerful enough to handle a lot of cases on its own. But at some point, the built-in tools start showing their limits. In this post, we'll start with Vue's basic state patterns, push them until they break, and then bring in Pinia to see what it actually solves.
The simplest way to share state across components in Vue is to export a reactive object from a module:
// stores/cart.ts
import { reactive, computed } from 'vue';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
const state = reactive({
items: [] as CartItem[],
});
export function useCart() {
const total = computed(() =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = state.items.find((i) => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
state.items.push({ ...item, quantity: 1 });
}
}
function removeItem(id: string) {
const index = state.items.findIndex((i) => i.id === id);
if (index > -1) {
state.items.splice(index, 1);
}
}
function clear() {
state.items.splice(0);
}
return { items: state.items, total, addItem, removeItem, clear };
}
Any component can import and use this:
<!-- components/CartSummary.vue -->
<script setup lang="ts">
const { items, total } = useCart();
</script>
<template>
<div>
<p>{{ items.length }} items in cart</p>
<p>Total: ${{ total }}</p>
</div>
</template>
This works. The state is reactive, shared across components, and the API is clean. For many small apps, this is all you need.
When you want scoped state (one instance per subtree instead of a global singleton), Vue's provide/inject is the tool:
// composables/useCartProvider.ts
import { provide, inject, reactive, computed } from 'vue';
import type { InjectionKey } from 'vue';
interface CartState {
items: CartItem[];
total: ComputedRef<number>;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (id: string) => void;
}
const CartKey: InjectionKey<CartState> = Symbol('cart');
export function provideCart() {
const items = reactive<CartItem[]>([]);
const total = computed(() =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = items.find((i) => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
items.push({ ...item, quantity: 1 });
}
}
function removeItem(id: string) {
const index = items.findIndex((i) => i.id === id);
if (index > -1) {
items.splice(index, 1);
}
}
const cart: CartState = { items, total, addItem, removeItem };
provide(CartKey, cart);
return cart;
}
export function useCart() {
const cart = inject(CartKey);
if (!cart) {
throw new Error('useCart() called without provideCart()');
}
return cart;
}
A parent component provides the state:
<!-- pages/shop.vue -->
<script setup lang="ts">
provideCart();
</script>
<template>
<div>
<ProductList />
<CartSummary />
</div>
</template>
And children inject it:
<!-- components/CartSummary.vue -->
<script setup lang="ts">
const { items, total } = useCart();
</script>
This keeps the state scoped to a component tree. Multiple shop sections could each have their own cart.
Both of the above patterns work for straightforward cases. But they start causing friction when:
No devtools support. You can't inspect the reactive module or provide/inject state in Vue DevTools. You have no way to see what the current state looks like, what mutations happened, or time-travel through changes.
SSR hydration issues. In Nuxt or any SSR setup, module-level reactive() creates a singleton that's shared across all requests on the server. This means state from one user's request can leak into another's. It's a real bug that's hard to catch in development.
// This is a server-side state leak in SSR
const state = reactive({
items: [] as CartItem[],
});
No standardized action pattern. As the store grows, there's no convention for where async logic lives, how errors are handled, or how actions compose. Every developer on the team invents their own pattern.
Testing is awkward. You can't easily reset a module-level reactive object between tests without manual cleanup. Provide/inject requires mounting a component tree just to test store logic.
Pinia solves these specific problems. Let's rebuild the cart with Pinia:
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([]);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
function addItem(item: Omit<CartItem, 'quantity'>) {
const existing = items.value.find((i) => i.id === item.id);
if (existing) {
existing.quantity++;
} else {
items.value.push({ ...item, quantity: 1 });
}
}
function removeItem(id: string) {
const index = items.value.findIndex((i) => i.id === id);
if (index > -1) {
items.value.splice(index, 1);
}
}
function clear() {
items.value = [];
}
return { items, total, addItem, removeItem, clear };
});
The API looks almost identical to the reactive module pattern. The difference is what happens behind the scenes.
Using it in a component is the same:
<!-- components/CartSummary.vue -->
<script setup lang="ts">
const cart = useCartStore();
</script>
<template>
<div>
<p>{{ cart.items.length }} items in cart</p>
<p>Total: ${{ cart.total }}</p>
</div>
</template>
DevTools integration. Open Vue DevTools and you can see every Pinia store, its current state, and a timeline of every action that was called. You can edit state in real time and time-travel through changes. This alone is worth it for any app you'll need to debug.
SSR safety. Pinia is SSR-aware. Each request gets its own store instance on the server, so there's no cross-request state leakage. In Nuxt, Pinia works out of the box with @pinia/nuxt:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
});
$reset and $patch. Pinia stores expose $reset() to restore initial state (great for testing and logout flows) and $patch() for batched updates:
const cart = useCartStore();
// Reset to initial state
cart.$reset();
// Batch multiple changes
cart.$patch({
items: [],
});
// Patch with a function for complex updates
cart.$patch((state) => {
state.items = state.items.filter((item) => item.quantity > 0);
});
Store subscriptions. You can watch for state changes and action calls:
const cart = useCartStore();
// React to any state change
cart.$subscribe((mutation, state) => {
localStorage.setItem('cart', JSON.stringify(state.items));
});
// React to action calls
cart.$onAction(({ name, args, after, onError }) => {
console.log(`Action ${name} called with`, args);
after((result) => {
console.log(`Action ${name} completed`);
});
onError((error) => {
console.error(`Action ${name} failed`, error);
});
});
Testing support. Pinia stores are easy to test in isolation:
import { setActivePinia, createPinia } from 'pinia';
import { useCartStore } from './cart';
describe('Cart Store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('adds an item', () => {
const cart = useCartStore();
cart.addItem({ id: '1', name: 'Widget', price: 10 });
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(10);
});
it('starts fresh each test', () => {
const cart = useCartStore();
expect(cart.items).toHaveLength(0);
});
});
Each test gets a fresh Pinia instance. No manual cleanup needed.
In Nuxt, you often need to populate a store from server-side data. Here's a pattern that works well:
// stores/products.ts
export const useProductsStore = defineStore('products', () => {
const items = ref<Product[]>([]);
const loaded = ref(false);
async function fetchProducts() {
if (loaded.value) return;
items.value = await $fetch('/api/products');
loaded.value = true;
}
return { items, loaded, fetchProducts };
});
<!-- pages/products.vue -->
<script setup lang="ts">
const store = useProductsStore();
await useAsyncData('products', () => store.fetchProducts());
</script>
<template>
<ul>
<li v-for="product in store.items" :key="product.id">
{{ product.name }}
</li>
</ul>
</template>
useAsyncData handles the SSR execution and hydration. The store action does the actual fetching. The loaded flag prevents refetching on client-side navigation.
Here's a quick way to think about it:
Stick with basic reactivity when:
Reach for Pinia when:
The migration from a reactive module to Pinia is small. The API shape is nearly identical. The cost of adding Pinia is low, and you get to defer the decision until the basic approach starts causing friction.
reactive() module pattern works well for simple shared state in client-only appsprovide/inject gives you scoped state per component treereactive() is unsafe in SSR because it shares state across requests$reset, $patch, subscriptions, and clean testinguseAsyncData for server-fetched stateWe started with Vue's built-in reactivity, pushed it until the edges showed, and then saw how Pinia addresses those gaps. The takeaway isn't that you always need Pinia. It's that you should know what you're giving up without it so you can make the call based on your actual project needs.
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Writing Custom Hooks in React: Patterns, Pitfalls, and When to Reach for One
A practical guide to writing custom React Hooks: the patterns they replaced, the rules they must follow, when to extract one, and libraries that cover the rest.
Aurora Scharff
May 21, 2026

State Management in Nuxt: Pinia or sticking to basics?
Using Pinia vs Basic State Management: When Vue's built-in reactivity is enough and when Pinia earns its place in your project.
Reza Baar
May 20, 2026

How Eloquent Actually Builds Your Models
A deep dive into Laravel Eloquent under the hood ā explore how models are resolved, hydrated, and persisted, and uncover the internal mechanics most developers use daily but rarely fully understand.
Steve McDougall
May 14, 2026
We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.
Let us help you get the resources you need.
