Introduction
If you've been building Vue.js applications for any length of time, you've probably used Vuex for state management. It was the go-to solution for years. But with Vue 3, a new challenger arrived — Pinia — and it's now the officially recommended state management library.
In this article, I'll compare both from a practical perspective, using real patterns I've used in production projects including a large-scale job portal built with Laravel + Vue 3.
Pinia is now the official state management library for Vue 3 as of 2023. Vuex 5 development has been paused and the team recommends migrating to Pinia for new projects.
Setup & Boilerplate
One of the first things you notice switching from Vuex to Pinia is how much less code you write. Let's see a simple user store in both:
Vuex 4 — User Store
import { createStore } from 'vuex' export default createStore({ state: () => ({ user: null, isLoading: false, }), getters: { isLoggedIn: (state) => !!state.user, userName: (state) => state.user?.name, }, mutations: { SET_USER(state, user) { state.user = user }, SET_LOADING(state, val) { state.isLoading = val }, }, actions: { async fetchUser({ commit }) { commit('SET_LOADING', true) const res = await api.get('/user') commit('SET_USER', res.data) commit('SET_LOADING', false) } } })
Pinia — Same Store (50% less code)
import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useUserStore = defineStore('user', () => { // State — just refs! const user = ref(null) const isLoading = ref(false) // Getters — computed() const isLoggedIn = computed(() => !!user.value) const userName = computed(() => user.value?.name) // Actions — plain async functions async function fetchUser() { isLoading.value = true user.value = (await api.get('/user')).data isLoading.value = false } return { user, isLoading, isLoggedIn, userName, fetchUser } })
Pinia eliminates mutations entirely. You mutate state directly in actions. No more commit('SET_USER') boilerplate — just user.value = data.
TypeScript Support
If you're using TypeScript (and you should be for any large SPA), Pinia wins by a massive margin. Vuex 4's TypeScript support requires significant workarounds — manual type augmentation, typed dispatch wrappers, and so on.
interface CartItem { id: number name: string price: number quantity: number } export const useCartStore = defineStore('cart', () => { const items = ref<CartItem[]>([]) const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.quantity, 0) ) function addItem(item: CartItem) { const existing = items.value.find(i => i.id === item.id) if (existing) existing.quantity++ else items.value.push(item) } return { items, total, addItem } })
Full type inference, zero boilerplate. Your IDE autocompletes everything from useCartStore() directly.
DevTools & Debugging
Both have Vue DevTools integration, but the experience differs. Pinia shows each store independently with its state, getters, and a full action history. You can even time-travel debug in Pinia by inspecting action snapshots.
In Vuex, all state lives in one tree which can get overwhelming in large apps. Pinia's modular store approach keeps DevTools clean and focused.
Side-by-Side Comparison
Here's a full breakdown of both libraries across the features that matter most in production:
| Feature | Pinia | Vuex 4 |
|---|---|---|
| Vue 3 Support | ✓ Native | ~ Compatible |
| TypeScript | ✓ First-class | ✗ Workarounds needed |
| Boilerplate | ✓ Minimal | ✗ Verbose |
| Mutations required | ✓ No | ✗ Yes |
| Composition API | ✓ Native | ~ Limited |
| Modular by default | ✓ Yes | ~ Namespaced |
| SSR Support | ✓ Full | ✓ Full |
| Bundle size | ✓ ~1kb | ~ ~10kb |
| Official recommendation | ✓ Recommended | ✗ Maintenance only |
| Learning curve | ✓ Low | ~ Medium |
Real-World Usage Pattern
Here's how I structure Pinia stores in a real Laravel + Vue 3 project. The pattern below handles API calls, error states, and loading indicators cleanly:
export const useJobStore = defineStore('jobs', () => { const jobs = ref([]) const loading = ref(false) const error = ref(null) const meta = ref({}) // pagination const totalJobs = computed(() => meta.value.total ?? 0) async function fetchJobs(params = {}) { loading.value = true error.value = null try { const { data } = await api.get('/jobs', { params }) jobs.value = data.data meta.value = data.meta } catch (err) { error.value = err.response?.data?.message ?? 'Failed to load jobs' } finally { loading.value = false } } return { jobs, loading, error, meta, totalJobs, fetchJobs } })
Using the store in a component
<script setup> import { onMounted } from 'vue' import { useJobStore } from '@/stores/jobs' const jobStore = useJobStore() onMounted(() => jobStore.fetchJobs()) </script> <template> <div v-if="jobStore.loading">Loading...</div> <div v-else-if="jobStore.error">{{ jobStore.error }}</div> <ul v-else> <li v-for="job in jobStore.jobs" :key="job.id"> {{ job.title }} <!-- clean, no mapGetters! --> </li> </ul> </template>
When To Still Use Vuex
Pinia is the better choice for almost all new Vue 3 projects. However, there are still situations where Vuex makes sense:
- You're maintaining a legacy Vue 2 codebase — Vuex 3 is built for Vue 2
- Your team is already deeply familiar with Vuex patterns and migration isn't worth the effort
- You have a very large existing codebase where a Vuex → Pinia migration would be high risk
Use Pinia for all new Vue 3 projects. It's the official recommendation, has a fraction of the boilerplate, and TypeScript support is first-class. The learning curve is minimal if you already know the Composition API.
If you're on an existing Vuex project, migrate incrementally — Pinia can coexist with Vuex during transition. Start new features in Pinia stores and gradually replace Vuex modules.