Let’s be honest for a second. Have you ever built a Vue app that started out simple, but then… well, it grew? Before you know it, you’re passing props down five levels deep and emitting events back up through a tangled mess of components. We call it "prop drilling," and it feels like trying to thread a needle in the dark.
I’ve been there. I remember wrestling with early state management, feeling like I needed a PhD in computer science just to share a username across my app. Then Vuex came along, and it was a lifesaver—but it had its own quirks, right? All that boilerplate with mutations, actions, modules… it could feel a little… rigid.
And then came Pinia.
Pinia is the official, recommended state management library for Vue.js now, and it’s not just a replacement for Vuex; it’s a complete rethinking of what state management should feel like. It’s simple, intuitive, and frankly, a joy to work with. If you've been putting off learning Pinia state management, this is your sign. Let's dive in.
So, Why Pinia? What’s the Big Deal?
Think of Pinia as the friendly guide who simplifies everything. It was created by Eduardo San Martin Morote (a core Vue team member), and it’s built right on top of the Vue 3 Composition API, making it incredibly powerful and flexible.
Here’s why I personally switched and never looked back:
- It’s just… easier. The API is clean and direct. No more separate
mutations. You just change the state. It feels natural, the way it was always meant to be. - Incredible TypeScript support. Type inference is a first-class citizen. Your editor will thank you, and you’ll catch bugs before they even happen. It’s a beautiful thing.
- Modular by nature. Every store is its own self-contained module. No more wrestling with complex nested Vuex modules.
- Feather-light. It’s only about 1kb. You get all this power without bloating your app.
It’s the breath of fresh air the Vue ecosystem needed. Ready to see for yourself?
Getting Pinia Up and Running
Alright, enough talk. Let's get our hands dirty. The setup is ridiculously simple.
First, pop open your terminal and install Pinia in your Vue project.
npm install pinia
Or if you're a yarn person:
yarn add pinia
Next, you just need to tell your Vue app to use it. Head over to your main.js (or main.ts) file and add a couple of lines.
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
// 1. Create a Pinia instance
const pinia = createPinia()
const app = createApp(App)
// 2. Tell Vue to use it
app.use(pinia)
app.mount('#app')
And… that’s it. Seriously. You’re now ready to create your first store. See? I told you it was friendly.
The Heart of Pinia: Your First Store
A "store" in Pinia is just a reactive object that holds your global state. I like to think of it as a component that never renders—it just holds data and logic that any other component can access.
Let's create a classic counter store. It’s the "Hello, World!" of state management. Go ahead and create a new directory src/stores and inside it, a file named counter.js.
// src/stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// State: The core data of your store
state: () => ({
count: 0,
name: 'My Awesome Counter',
}),
// Getters: Computed properties for your store
getters: {
doubleCount: (state) => state.count * 2,
// You can also use `this` to access other getters
doubleCountPlusOne() {
return this.doubleCount + 1;
},
},
// Actions: Methods to change the state
actions: {
increment() {
this.count++;
},
decrement() {
this.count--;
},
incrementBy(amount) {
this.count += amount;
},
},
})
Let's break this down, because this is the foundation for everything.
defineStore: This is the magic function. The first argument,'counter', is a unique ID for this store. Pinia uses this ID to connect the store to the devtools, so make it meaningful!state: This is a function that returns the initial state of your store. It’s basically thedataof a component. We use an arrow function here to make sure the state is reactive and works correctly on both the client and server.getters: These are your store’scomputedproperties. They're perfect for calculating derived state. NoticedoubleCounttakesstateas an argument. You can also define a getter as a regular function to access other getters viathis. Pretty neat.actions: These are your store’smethods. Actions are where you define the logic to update your state. And here's the beautiful part—you can directly mutate state withthis.count++. No more ceremoniouscommit('INCREMENT')like in Vuex. It’s direct, simple, and clean.
Bringing Your Store to Life in a Component
Okay, we have a store. Cool. But it's pretty useless until we can actually use it. Let's hook it up to a Vue component.
I’m using <script setup>, which is the modern standard. If you haven't tried it yet, you're in for a treat.
<script setup>
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';
// Initialize the store
const counterStore = useCounterStore();
// 🚨 Heads up! This is a classic "gotcha".
// If you destructure directly, you lose reactivity.
// const { count, doubleCount } = counterStore; // This is BAD! ❌
// ✅ This is the correct way to get reactive refs from the store.
const { count, name, doubleCount } = storeToRefs(counterStore);
// Actions are just functions, so you can destructure them safely.
const { increment, decrement } = counterStore;
</script>
<template>
<div>
<h1>{{ name }}</h1>
<h2>Count: {{ count }}</h2>
<p>Double Count: {{ doubleCount }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<!-- You can also call actions directly from the store instance -->
<button @click="counterStore.incrementBy(10)">+10</button>
<!-- Or even... mutate state directly! (Use with care) -->
<button @click="counterStore.count = 0">Reset</button>
</div>
</template>
This is a really important part, so let’s pause here for a sec. The biggest mistake I see beginners make is trying to destructure state properties directly from the store. I've done it myself more times than I'd like to admit.
Why doesn't const { count } = counterStore work? Because it pulls out the raw value (0), not the reactive ref that Vue needs to track changes. To solve this, Pinia gives us a handy utility: storeToRefs. It wraps every state property and getter in a reactive ref, so your component will update whenever the state changes. It's a lifesaver.
Actions, on the other hand, are just methods. You can destructure them or call them directly from the store instance (counterStore.increment()). It’s all good.
A More Modern Flavor: The Composition "Setup" Syntax
The "Options API" style we just used is great and very clear. But if you’re all-in on the Composition API (and I think you should be), Pinia offers a "setup store" syntax that feels even more native to modern Vue.
Let’s create a new user store this way.
// src/stores/user.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
// State -> ref()
const user = ref(null);
const isLoading = ref(false);
// Getters -> computed()
const isLoggedIn = computed(() => user.value !== null);
const userName = computed(() => user.value?.name || 'Guest');
// Actions -> function()
async function login(credentials) {
isLoading.value = true;
try {
// Fake API call
const response = await new Promise(resolve =>
setTimeout(() => resolve({ name: 'Alice', email: 'alice@example.com' }), 1000)
);
user.value = response;
} catch (error) {
console.error('Login failed:', error);
user.value = null;
} finally {
isLoading.value = false;
}
}
function logout() {
user.value = null;
}
// You MUST return everything you want to expose
return { user, isLoading, isLoggedIn, userName, login, logout }
})
Do you see it? It’s just a Composition API setup function!
ref()becomes your state.computed()becomes your getters.function()becomes your actions.
Let's Build Something Real: A Todo App
The counter is a good start, but let's tackle something with a bit more meat. A todo list is the perfect candidate for demonstrating Pinia state management in a more real-world scenario.
Here’s our todos store. It's a bit longer, but you'll recognize all the same concepts we've just talked about.
// src/stores/todos.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useTodosStore = defineStore('todos', () => {
const todos = ref([
{ id: 1, text: 'Learn Vue.js', completed: true },
{ id: 2, text: 'Learn Pinia', completed: false },
{ id: 3, text: 'Build something awesome', completed: false },
]);
const filter = ref('all'); // 'all', 'active', 'completed'
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed);
case 'completed':
return todos.value.filter(t => t.completed);
default:
return todos.value;
}
});
const activeCount = computed(() => {
return todos.value.filter(t => !t.completed).length;
});
function addTodo(text) {
if (!text.trim()) return;
todos.value.push({
id: Date.now(),
text,
completed: false,
});
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}
function removeTodo(id) {
todos.value = todos.value.filter(t => t.id !== id);
}
function setFilter(newFilter) {
filter.value = newFilter;
}
return {
todos,
filter,
filteredTodos,
activeCount,
addTodo,
toggleTodo,
removeTodo,
setFilter,
}
})
Notice how clean this is? The logic for filtering todos is neatly tucked away in a computed getter. The actions for adding, toggling, and removing todos are just simple functions that manipulate the todos array.
Using it in a component is exactly the same as before. You'd just import useTodosStore, pull out what you need with storeToRefs, and bind the actions to your UI events. Easy peasy.
Going Deeper: Advanced Pinia Patterns
Once you're comfortable with the basics, Pinia has some powerful features that can solve more complex problems you'll eventually run into.
When Stores Need to Talk
What if your cartStore needs to know if a user is logged in before adding an item? In Pinia, stores can just import and use each other. It’s that simple. It almost feels too easy.
// src/stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user' // 👈 Import the other store
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
actions: {
addItem(product) {
const userStore = useUserStore(); // 👈 Use it right here!
if (!userStore.isLoggedIn) {
alert('You must be logged in to add items to the cart.');
return;
}
this.items.push(product);
console.log(`${userStore.userName} added ${product.name} to the cart.`);
},
},
});
No complex setup, no weird module registration. You just import it and use it. This makes building modular, interconnected systems an absolute breeze.
Making State Stick Around with Persistence
By default, your Pinia state disappears when you refresh the page. What if you want to keep the user logged in or save their cart contents? You need persistence.
While you could wire up localStorage manually, there’s a fantastic plugin that does it all for you.
First, install it:
npm install pinia-plugin-persistedstate
Then, register it with Pinia in your main.js:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 👈 Import
import App from './App.vue'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 👈 Use the plugin
const app = createApp(App)
app.use(pinia)
app.mount('#app')
Now, in any store you want to persist, just add one magical line:
// src/stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
token: null,
}),
// ... getters and actions
persist: true, // 👈 That's it!
})
Boom. Just like that, your user and token will now be saved to localStorage automatically and rehydrated on page load. You can even configure it to use sessionStorage or only persist certain parts of your state. It's incredibly powerful. For more details, the official docs are a great resource.
Final Thoughts: Why Pinia is a Game-Changer
We've covered a lot of ground, from creating your first store to advanced patterns like cross-store communication and persistence. The common thread through it all? Simplicity.
Pinia state management gets out of your way and lets you focus on building your application. It ditches the boilerplate and ceremony of older solutions and embraces the modern, reactive patterns of Vue 3.
It’s not just a library; it’s a productivity boost. It makes your code easier to read, easier to test, and easier to maintain. And in my experience, that’s what truly great tools do. They don't just solve a problem—they make the entire development experience better.
So go on, give it a try. I have a feeling you won't be looking back.
Frequently Asked Questions
Is Pinia replacing Vuex? Yep! Pinia is now the official recommendation for state management in Vue. The Vue team recommends starting new projects with Pinia. While Vuex still works and is maintained, Pinia is the future.
Do I need Pinia for every Vue project? Absolutely not! For small applications, Vue's built-in reactivity system (props, events, and
provide/inject) might be all you need. Pinia shines when you have "global" state that needs to be accessed by many distant components, like user authentication, a shopping cart, or global notifications. If you're starting to feel the pain of prop drilling, it's definitely time for Pinia.
Can I use Pinia with the Vue 2 Options API? You bet. While Pinia is designed with the Composition API in mind, it works perfectly well with the classic Options API. You'd typically use a helper like
mapStateormapActionsto connect it to your components, much like you would with Vuex.
Pinia vs. Vuex: What's the biggest difference? The biggest philosophical difference is the removal of
mutations. In Vuex, only mutations could change state, and they had to be synchronous. Actions would call mutations. Pinia simplifies this: actions can directly mutate the state, and they can be async. This cuts down on so much boilerplate and makes the data flow much more direct and intuitive.