
When you build a Vue 3 app, you inevitably face this: how should I manage the state? Do I just use the built-in features like ref, reactive, and composables via the Vue Composition API — or do I bring in a dedicated library like Pinia? This article will dive deep, get intense, pull apart the pros and cons, show you real code, ask you tough questions + give answers, and leave you with tasks. We’ll cover why it matters, when you might choose one over the other, and how you actually implement each.
Why state management even matters, and why this decision is HUGE
If your app is tiny – maybe one or two components – maybe state is trivial. But once you start growing: dozens of components, nested trees, modules, asynchronous fetches, caching, derived state, multiple teams working… the “state management” piece becomes mission critical. Mess it up, and you’ll have bugs, spaghetti, unpredictable side-effects, data out of sync, re-render chaos, and maintenance nightmares.
With Vue 3, you have this amazing power: the Composition API. It gives you flexibility, less ceremony, and the feeling of “just write plain JS”. On the flip side, you have Pinia, designed to be the “official” store library for Vue, offering structure, patterns, tooling, and ready-made store features.
This decision – “do I just in-house build my store logic via composables or adopt Pinia?” – can shape how scalable your app is, how maintainable, how easy to onboard new developers, and how comfortable you’ll be when things go wrong. The stakes are high.
Using Composition API (no external store library) – what it looks like, its benefits & risks
What it looks like
With the Composition API approach, you lean on these built-in features:
-
ref(),reactive(),computed(),watch() - You write your own “store” modules as simple JS/TS modules or composables
- You might use
provide/injectto share state across component trees - You don’t add a dedicated store library (so fewer dependencies)
- Example:
// src/composables/useUsers.ts
import { ref, onMounted } from 'vue’
import type { Ref } from 'vue’
interface User {
id: number
name: string
}
export function useUsers(): { users: Ref<User[]>; loadUsers: () => Promise<void> } {
const users = ref<User[]>([])
async function loadUsers(): Promise<void> {
// pretend fetchUsers fetches User[]
users.value = await fetchUsers()
}
onMounted(() => {
void loadUsers()
})
return { users, loadUsers }
}
Then to distribute it:
// injection-keys.ts
import type { InjectionKey } from 'vue’
import type { UsersHelper } from './useUsers’
export const usersHelperKey: InjectionKey<UsersHelper> = Symbol('usersHelper')
// ParentComponent.vue
<script setup lang="ts">
import { provide } from 'vue’
import { useUsers } from '@/composables/useUsers’
import { usersHelperKey } from '@/composables/injection-keys’
const usersHelper = useUsers()
provide(usersHelperKey, usersHelper)
</script>
<template>
<ChildComponent />
</template>
// ChildComponent.vue
<script setup lang="ts">
import { inject } from 'vue’
import { usersHelperKey } from '@/composables/injection-keys’
const usersHelper = inject(usersHelperKey)
</script>
<template>
<ul>
<li v-for="u in usersHelper?.users" :key="u.id">{{ u.name }}</li>
</ul>
</template>
Which shows: you can build your own mini store using the Composition API + provide/inject.
Benefits
- No extra library to learn/install/configure → fewer dependencies.
- Very flexible: you tailor your “store” exactly how you want it.
- Lightweight: if your state needs are modest, this is ideal.
- Keeps parts of your app separated (you only share what you want).
- Great for small to medium projects, proof-of-concepts, or apps with limited state complexity.
Risks / drawbacks
- As your app grows, you’ll likely end up reinventing parts of what store libraries already solve: modules, namespacing, devtools support, persist, hot reload, debugging etc.
- Sharing global state or cross-tree state gets more manual (provide/inject or exporting singletons) – potential for mis-wiring or accidental coupling.
- Without clear structure, you can drift into chaos: many files, inconsistent patterns, multiple “useX” composables doing similar things but subtle differences.
- Harder to enforce patterns (especially across teams) or to debug/travel history of state changes.
- If you plan on many modules, many developers, large codebase – you’ll probably pay cost in maintainability.
Using Pinia – what it looks like, why it’s powerful, and when you’ll want it
What it looks like
Pinia gives you a structured store definition; it’s built for Vue 3, and integrates with devtools. Example:
// src/stores/counter.ts
import { defineStore } from 'pinia’
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
getters: {
doubleCount(state) {
return state.count * 2
}
},
actions: {
increment(amount = 1) {
this.count += amount
}
}
})
Then in a component:
<script setup lang="ts">
import { storeToRefs } from 'pinia’
import { useCounterStore } from '@/stores/counter’
const counter = useCounterStore()
const { count, doubleCount } = storeToRefs(counter)
function handleClick() {
counter.increment()
}
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="handleClick">Increment</button>
</div>
</template>
That’s it. The structure is obvious: you have state, getters, actions. Pinia gives you all the things you expect from a store library, but with minimal boilerplate.
Why it’s powerful
- Global registration: you can access your store from anywhere in the app easily.
- DevTools integration: you get time-travel, inspect state changes, debug flows more easily.
- Orderly structure: modules (stores) are well defined; no implicit “magic” leaks.
- TypeScript support is strong (Pinia was built with TS in mind) → fewer type headaches.
- Scaling is easier: when you add more features, more modules, more devs, the store architecture is solid.
- Community momentum: since many Vue 3 apps now adopt Pinia, you’ll find patterns, plugins, ecosystem support.
When you’ll want it
- If you’re building a large or medium-sized app (many components, many modules, cross-domain state).
- If multiple developers will touch the state layer (you want consistency, predictability).
- If you care about debugging, state traceability, devtools, hot module replacement.
- If your state management is a non-trivial part of your app (carts, user sessions, persistent state, multiple modules, real-time events etc.).
- If you want fewer custom “home-rolled” solutions and prefer something you can rely on.
Comparison – side by side
Here’s a more extreme breakdown of how they differ, when one shines, when one struggles.
| Feature / Concern | Composition API (no store library) | Pinia |
|---|---|---|
| Setup overhead | Very low – just use composables + built-in APIs | Low to medium – install Pinia, register plugin, then define stores |
| Learning curve | Familiar JS + Vue concepts; freedom to design your own | Slight overhead to learn Pinia APIs, but straightforward |
| Dependency | Zero external store library; minimal footprint | One additional library (Pinia) added to your stack |
| Structure / Architecture | Loose – you design modules, naming, boundaries yourself | Defined – you create stores with state/getters/actions; architecture is more consistent |
| Sharing global state | Manual sharing (export singletons or provide/inject) | Built-in global shared store support |
| Debugging / Dev tools | More manual – you may need to create custom hooks/logging | Rich dev tools, time-travel, plugins, clear store snapshots |
| TypeScript support | Good but you may need more manual type definition or boilerplate | Very good – Pinia supports TS out of the box nicely |
| Scaling to large apps | Requires discipline, good architecture; risk of fragmentation | Built for scaling; modules, stores, team collaboration easier |
| Flexibility / freedom | Maximum freedom – you decide everything | Slightly less freedom (you follow Pinia’s pattern) but still very flexible |
| Lightweight apps | Ideal for small apps where adding a library feels heavy | Works, but may feel like overkill for very tiny apps |
| Team / maintainability | Requires standards, conventions to avoid spaghetti | Out-of-the-box conventions assist maintainability |
The verdict (with some extreme emphasis)
If you are working on a small application or you know for sure that your state needs are modest, you might choose just the Composition API. It’s lean, fast to set up, no extra dependencies, and you can keep things simple.
But if you are building something non-trivial, or expect growth, or expect more team members, or you want the comfort of structured state management, then Pinia is the much safer bet. The added structure, tooling, clarity will repay you hugely when things get complex.
In fact: if I were building a “real world” app today, I would probably start with Pinia unless I was absolutely sure the app will remain trivial. Because once you reach a certain size, switching later is more painful than starting with the right tool.
Real-world code examples + comparisons
Here are two scenarios:
- Using Composition API only
- Same scenario using Pinia
Example Scenario: Shopping Cart
Composition API version
// src/composables/useCart.ts
import { reactive, computed } from 'vue’
interface CartItem {
id: number
name: string
quantity: number
price: number
}
const state = reactive({
items: [] as CartItem[]
})
export function useCart() {
const items = computed(() => state.items)
const totalQuantity = computed(() => state.items.reduce((sum, i) => sum + i.quantity, 0))
const totalPrice = computed(() => state.items.reduce((sum, i) => sum + i.quantity * i.price, 0))
function addItem(item: CartItem) {
const existing = state.items.find(i => i.id === item.id)
if (existing) {
existing.quantity += item.quantity
} else {
state.items.push(item)
}
}
function removeItem(id: number) {
const index = state.items.findIndex(i => i.id === id)
if (index !== -1) {
state.items.splice(index, 1)
}
}
return {
items,
totalQuantity,
totalPrice,
addItem,
removeItem
}
}
Then in your components you import useCart() and everything is reactive. You don’t need to install anything extra.
Pros: simple, direct.
Cons: if you add many modules (cart, user, products, orders), you’ll need to manage each module’s file, potentially manage injection or exports. Debugging becomes manual. You’ll need to enforce “who uses what state” and avoid cross-module entanglement.
Pinia version
// src/stores/cart.ts
import { defineStore } from 'pinia’
interface CartItem {
id: number
name: string
quantity: number
price: number
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[]
}),
getters: {
totalQuantity: (state) => state.items.reduce((sum, i) => sum + i.quantity, 0),
totalPrice: (state) => state.items.reduce((sum, i) => sum + i.quantity * i.price, 0)
},
actions: {
addItem(item: CartItem) {
const existing = this.items.find(i => i.id === item.id)
if (existing) {
existing.quantity += item.quantity
} else {
this.items.push(item)
}
},
removeItem(id: number) {
const index = this.items.findIndex(i => i.id === id)
if (index !== -1) {
this.items.splice(index, 1)
}
}
}
})
And in component:
<script setup lang="ts">
import { storeToRefs } from 'pinia’
import { useCartStore } from '@/stores/cart’
const cart = useCartStore()
const { items, totalQuantity, totalPrice } = storeToRefs(cart)
function handleAdd() {
cart.addItem({ id: 1, name: 'Widget', quantity: 2, price: 10 })
}
</script>
<template>
<div>
<p>Total Items: {{ totalQuantity }}</p>
<p>Total Price: {{ totalPrice }}</p>
<button @click="handleAdd">Add Item</button>
<ul>
<li v-for="i in items" :key="i.id">
{{ i.name }} × {{ i.quantity }} — ${{ i.price * i.quantity }}
<button @click="cart.removeItem(i.id)">Remove</button>
</li>
</ul>
</div>
</template>
Pros: structured, debugging tools, predictable, scalable.
Cons: slightly more setup, you must adopt Pinia’s style/patterns (which is fine for most). For trivial apps maybe it’s overhead.
Questions & Answers section
Q1. Is it wrong to use the Composition API only for everything, never using Pinia?
A1. Not at all wrong – for small or medium apps, Composition API alone is fully valid. But you must be disciplined: define modules, separate concerns, ensure you don’t end up with one giant “state bag” that everything reaches into. If you foresee growth, you’ll likely pay later.
Q2. If I start with Composition API and later switch to Pinia, will it be hard?
A2. It depends on how you structured things. If you kept things modular (each composable handles its own concerns) and you avoided ad-hoc state access everywhere, you’ll find migrating easier. If you have many components drilling state manually, you’ll probably need to refactor larger parts of your app.
Q3. Does Pinia lock me in or reduce flexibility?
A3. Not really. Pinia is quite flexible. You still write actions, getters, state. You can still use composables inside your stores. The pattern is clear but you retain freedom. The benefit is you get structure rather than rigid restriction.
Q4. What about performance – is using Pinia slower?
A4. In most cases, no significant difference. Pinia is lightweight and optimized for Vue 3. Unless you have extremely tight performance constraints, the overhead is negligible compared to the developer benefit of structure and tooling.
Q5. What factors should influence my choice between the two?
A5. Factors to check:
- How many modules / domains in your state (cart, user, products, etc.).
- Do you have multiple developers working on state?
- Do you need debugging tools / time-travel / devtools support?
- Will the state grow more complex (async, derived, caching, resets)?
- Is the application expected to scale significantly?
- How comfortable are you with building your own structure vs using a built-in one?
Tasks for you to try
Here are some hands-on tasks to cement the knowledge. Try both approaches.
- Task A (Composition API only):
- Create a simple blog app: posts + comments.
- Build a composable
usePosts()that loads posts from a mock API, tracks loading/error state, allows adding a post. - Build
useComments(postId)that loads comments for a given post, allows adding/removing comments. - Make sure you share state as needed between parent/child components (maybe via provide/inject) or a simple module export.
- Then measure: how easy is it to access posts + comments from arbitrary components? How easy to debug? What happens when state gets more complex (e.g., caching, concurrent loads)?
- Task B (Pinia version):
- Convert the blog app using Pinia. Create two stores:
postsStoreandcommentsStore. - In each store, keep state, getters (e.g., commentsCount), and actions (e.g., fetchPosts, addPost, fetchComments, addComment).
- Use the stores in components. Use devtools to inspect state changes.
- Reflect: How did the architecture differ? Which one felt cleaner? Which one easier to maintain when you add more features (e.g., editing posts, filtering posts, reacting to user login)?
- Task C (Scaling scenario):
- Pretend your blog app is going to expand: you’ll add user authentication, user preferences, theme switching, notifications, offline caching, and multiple teams contributing.
- Map out (by hand or in a doc) how you would structure your state modules in each approach (Composition API only vs Pinia).
- Identify potential pain points in each. For example: in the Composition API version you might have many small composables, some of them overlapping responsibilities; in the Pinia version you might need to decide on store names, module splitting, but you already have clear patterns.
- Decide which approach you would adopt for this scaling scenario and write down your reasoning.
Final thoughts (and a bit of hyperbole)
Let’s be clear: Choosing between “Composition API alone” vs “Pinia” is not just a small internal decision. It can define how maintainable, scalable, and pleasant your development experience will be. If you ignore structure now, you may pay for it later in chaotic merges, obscure bugs, duplicated logic, and developer frustration.
On the other hand, if you skip over-engineering and keep it simple when it's simple, you’ll save time, keep things nimble, and avoid bloat. There’s a sweet spot.
But please: don’t assume one size fits all. It’s okay to start simple. It’s okay to evolve. What matters is that you choose intentionally, not by default.
If I were to issue a bold statement:
"If you think your app will even remotely grow beyond trivial size, pick Pinia. If you’re absolutely sure it will stay tiny, the Composition API alone will serve."
And yes — if you pick Composition API now and later hit scaling constraints, migrating may cost more than deciding right up front. But it’s also true that starting with Pinia in a 2-page app might feel like using a sledgehammer to crack a nut.
In the end: you want clarity, predictability, maintainability, and developer happiness. Choose the tool that supports that for your context. Write your composables or build your stores, and keep your state management not just working — but elegant.
Top comments (0)