Pinia vs Vuex in 2026: Vue State Management Evolution
TL;DR
Pinia is the official Vue state management library. Vuex is in maintenance mode. Pinia (~2.5M weekly downloads) is lighter, has better TypeScript support, and drops Vuex's mutations in favor of direct state changes. Vuex (~2.8M downloads) is still widely used in legacy Vue 2/3 apps. For any new Vue 3 project, use Pinia. The Vuex → Pinia migration is well-documented and usually straightforward.
Key Takeaways
- Pinia: ~2.5M weekly downloads — Vuex: ~2.8M (much is legacy)
- Pinia is Vue's official recommendation — Evan You endorsed it, Vuex 5 was cancelled
- Pinia drops mutations — state changes directly in actions
- Pinia has first-class TypeScript — no need for complex generic gymnastics
- Pinia devtools work seamlessly — Vue DevTools extension supports both
Mutations vs Direct State Changes
The most visible difference between Vuex and Pinia is the mutation layer. In Vuex, mutations are synchronous functions that are the only legitimate way to change state. You dispatch an action (which can be async), and the action commits a mutation, and the mutation modifies state. This two-step pattern was intentional — it made state changes traceable in DevTools and prevented async state changes from creating race conditions. In practice, it created a lot of boilerplate: every state change required both a mutation definition and an action definition.
Pinia eliminated mutations entirely. In Pinia, you change state directly inside actions, or you call $patch() to apply multiple changes atomically. The DevTools still track every state change because Pinia instruments the store reactivity — you get full observability without the ceremony. This is the single most common reason Vue developers migrating from Vuex say the migration felt like removing scaffolding.
// Vuex 4 — strict mutation/action separation
import { createStore } from 'vuex';
const store = createStore({
state: {
count: 0,
user: null,
},
getters: {
doubleCount: (state) => state.count * 2,
isLoggedIn: (state) => !!state.user,
},
mutations: {
// Must be synchronous — the only way to modify state
INCREMENT(state) { state.count++; },
SET_USER(state, user) { state.user = user; },
LOGOUT(state) { state.user = null; },
},
actions: {
// Can be async — must commit mutations to change state
increment({ commit }) {
commit('INCREMENT');
},
async login({ commit }, credentials) {
const user = await api.login(credentials);
commit('SET_USER', user);
},
logout({ commit }) {
commit('LOGOUT');
},
},
});
// Pinia — no mutations, state changes in actions directly
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
count: 0,
user: null as User | null,
}),
getters: {
doubleCount: (state) => state.count * 2,
isLoggedIn: (state) => !!state.user,
},
actions: {
// Sync or async — modify state directly
increment() { this.count++; },
async login(credentials: Credentials) {
this.user = await api.login(credentials); // Direct assignment
},
logout() {
this.$patch({ user: null, count: 0 }); // $patch for multiple changes
},
},
});
The Pinia version has no mutations. The actions do what Vuex's mutations and actions did combined, but in half the code. Every state change is still tracked by Vue DevTools because Pinia is built on top of Vue's reactivity system.
A common concern when developers first encounter Pinia's direct state mutation is: "doesn't that make state changes harder to track?" The answer is no — Pinia instruments Vue's reactivity to intercept every state change, so DevTools still shows a timestamped history of every modification, regardless of whether the change came from an action, a $patch() call, or a direct assignment. The mutation layer in Vuex existed to enforce trackability, but Pinia achieves the same trackability through reactivity instrumentation rather than architectural restriction.
TypeScript Support: Night and Day
TypeScript support is the second major advantage Pinia has over Vuex. Vuex 4 added TypeScript support, but it required significant ceremony: you had to define an InjectionKey, augment the ComponentCustomProperties interface, type the state separately, and work around the fact that useStore() returns an untyped store unless you pass complex generics. Getters and mutations often lost type information in practice, especially in larger apps.
Pinia was designed for TypeScript from the start. When you define a store, TypeScript infers every type automatically: state fields, getter return types, and action parameters. You get full autocomplete on useStore().someField and type errors if you try to assign the wrong type. No augmentation, no generics, no InjectionKey. The experience is as natural as defining a class with TypeScript.
// Vuex with TypeScript — complex generics required
import { InjectionKey } from 'vue';
import { createStore, Store } from 'vuex';
// Step 1: Define state type
interface State {
count: number;
user: User | null;
}
// Step 2: Create injection key
export const key: InjectionKey<Store<State>> = Symbol();
// Step 3: Augment ComponentCustomProperties
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$store: Store<State>;
}
}
// Step 4: Create store
const store = createStore<State>({
state: { count: 0, user: null },
getters: {
// Getters still not fully typed without more work
isLoggedIn: (state): boolean => !!state.user,
},
mutations: {
SET_USER(state, user: User) { state.user = user; },
},
});
// Usage — still imperfect type safety
const store = useStore(key);
store.state.count; // Typed correctly
store.getters.isLoggedIn; // Type: any — getters lose types
// Pinia with TypeScript — just works, no ceremony
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null);
const isLoggedIn = computed(() => !!user.value);
const role = computed(() => user.value?.role ?? 'guest');
async function login(credentials: LoginCredentials) {
user.value = await api.login(credentials); // user.value is User | null — fully typed
}
function logout() { user.value = null; }
return { user, isLoggedIn, role, login, logout };
});
// Usage — full type inference automatically
const userStore = useUserStore();
userStore.user; // User | null — correctly inferred
userStore.isLoggedIn; // boolean — correctly inferred
userStore.login({ email: 'a@b.com', password: 'secret' }); // Type-checked
Store Composition
One of Pinia's most ergonomic features is the ability to use one store inside another. In Vuex, cross-module dependencies require namespaced module paths (store.getters['cart/total']) and careful namespace management. In Pinia, you simply call useOtherStore() inside an action or getter — it's the same hook pattern you use in components.
Pinia also supports two different store definition styles. The Options API style (with state, getters, actions keys) is familiar to Vuex users and easy to migrate to. The Composition API style (a setup function returning reactive refs and computed values) is more flexible and integrates naturally with Vue 3's composition API. You can mix both styles in the same app.
// Pinia — import other stores directly (store composition)
import { defineStore } from 'pinia';
import { useUserStore } from './user';
export const useCartStore = defineStore('cart', () => {
const userStore = useUserStore(); // Use another store directly
const items = ref<CartItem[]>([]);
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
);
async function checkout() {
if (!userStore.isLoggedIn) {
throw new Error('Must be logged in to checkout');
}
await api.checkout(items.value, userStore.user!.id);
items.value = [];
}
return { items, total, checkout };
});
// Vuex — cross-module access requires namespaces
const cartModule = {
namespaced: true,
getters: {
total: (state, getters, rootState, rootGetters) => {
const isLoggedIn = rootGetters['user/isLoggedIn']; // Namespace string
if (!isLoggedIn) return 0;
return state.items.reduce((sum, item) => sum + item.price, 0);
},
},
actions: {
async checkout({ state, rootGetters, commit }) {
const userId = rootGetters['user/userId']; // Another namespace string
await api.checkout(state.items, userId);
},
},
};
Migration from Vuex to Pinia
Migrating from Vuex to Pinia is conceptually straightforward because the two libraries have a close 1:1 mapping. Each Vuex module becomes a Pinia store. Mutations are merged into actions — in practice, you delete the mutation definition and move the body of the mutation into the action directly. State definitions barely change. Getters translate directly.
The Vue team documented the migration path in the official Vue docs, and most teams report completing the migration for a typical medium-sized app in a few days to a week. The main source of friction is finding all the places where components dispatch actions or commit mutations and updating the call signatures. Vuex usage like store.commit('moduleName/MUTATION_NAME', payload) becomes useModuleStore().actionName(payload) — same intent, much cleaner API.
One migration approach that works well for large codebases is incremental: Pinia and Vuex can coexist in the same Vue 3 app. You can migrate one Vuex module to a Pinia store, verify it works, and continue until all modules are migrated. There's no need to do a big-bang rewrite. The Vue docs specifically recommend this approach for large apps to reduce migration risk.
// Vuex module → Pinia store (1:1 mapping)
// BEFORE (Vuex module):
const cartModule = {
namespaced: true,
state: { items: [] as CartItem[] },
getters: {
total: (state) => state.items.reduce((sum, i) => sum + i.price, 0)
},
mutations: {
ADD_ITEM(state, item: CartItem) { state.items.push(item); },
REMOVE_ITEM(state, id: string) {
state.items = state.items.filter(i => i.id !== id);
},
},
actions: {
addItem({ commit }, item: CartItem) { commit('ADD_ITEM', item); },
removeItem({ commit }, id: string) { commit('REMOVE_ITEM', id); },
},
};
// AFTER (Pinia store) — mutations removed, actions simplified:
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as CartItem[] }),
getters: {
total: (state) => state.items.reduce((sum, i) => sum + i.price, 0),
},
actions: {
addItem(item: CartItem) { this.items.push(item); },
removeItem(id: string) {
this.items = this.items.filter(i => i.id !== id);
},
},
});
Plugins and Ecosystem
Pinia's plugin system is notably cleaner than Vuex's module system. You create a plugin as a function that receives the store and extends it — adding properties, wrapping actions, or reacting to state changes. Plugins are applied globally to all stores or conditionally to specific stores via convention.
The most popular Pinia plugin is pinia-plugin-persistedstate, which adds localStorage persistence to stores. You install the plugin once, then add persist: true to any store you want to persist. The plugin handles serialization, hydration, and storage key management. The equivalent in Vuex required either a custom plugin or a third-party library like vuex-persistedstate — same concept, but Pinia's plugin API is simpler to extend.
// Pinia plugins — applied globally to all stores
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// Per-store: opt into persistence
export const useSettingsStore = defineStore('settings', {
state: () => ({
theme: 'light' as 'light' | 'dark',
language: 'en',
}),
persist: {
key: 'user-settings',
storage: localStorage,
pick: ['theme', 'language'], // Only persist specific fields
},
});
// Custom plugin: add a $reset method to all stores
function resetPlugin({ store }) {
const initialState = JSON.parse(JSON.stringify(store.$state));
store.$reset = () => store.$patch(initialState);
}
pinia.use(resetPlugin);
The plugin pattern also makes it straightforward to integrate Pinia with Nuxt 3. @pinia/nuxt provides SSR hydration, auto-importing of stores, and DevTools integration out of the box. Vuex also has a Nuxt 3 module, but the Pinia integration is more seamlessly maintained since Pinia is the official recommendation.
One Pinia advantage that's easy to overlook in comparisons is the SSR hydration story. In a Nuxt 3 or Vite SSR application, Pinia stores are automatically scoped per request — there's no risk of state leaking between requests, which is a real concern with Vuex stores that are instantiated globally. The createPinia() call in the Nuxt plugin context ensures each server request gets a fresh store instance. This makes Pinia significantly safer for SSR applications without requiring explicit action from the developer.
Server components in Vue 3's ecosystem also benefit from Pinia's composition-style stores. Because composition stores return reactive refs and computed properties — the same primitives used in <script setup> — they integrate cleanly with <Suspense> and async component patterns. A composition store can include async setup logic, which Vuex modules couldn't express cleanly.
Package Health
| Metric | pinia | vuex |
|---|---|---|
| Weekly downloads | ~2.5M | ~2.8M |
| GitHub stars | ~13k | ~28k |
| Status | Actively developed | Maintenance mode |
| Vue 3 support | Full | Full |
| Vue 2 support | Via bridge | Full |
| TypeScript | First-class | Requires setup |
| Official Vue recommendation | Yes | No (deprecated for new projects) |
Vuex's higher GitHub star count reflects its long history — it was Vue's official state manager for years. But Vuex 5 was officially cancelled and Vuex 4 is now in maintenance-only mode. Pinia is the recommended library for all new Vue projects, endorsed by Evan You and the Vue core team.
When to Choose
Choose Pinia when:
- Building any new Vue 3 application — this is the default choice in 2026
- TypeScript is part of your stack — the type inference is dramatically better
- You want cleaner, more maintainable stores without the mutation ceremony
- You're migrating a Vuex project and the codebase is worth updating
- You want composition-style stores that feel like Vue 3 composables
Stick with Vuex when:
- You have a large, stable Vue 2 or Vue 3 codebase with Vuex deeply integrated and migration ROI is low
- You depend on specific Vuex plugins that have no Pinia equivalent
- Your team is deeply familiar with Vuex and the project is in maintenance, not active development
The trajectory is clear: Pinia is Vue's state management future. Vuex is maintenance-only, and no new Vue projects should start with it in 2026.
For download trend data and side-by-side health metrics, see the Pinia vs Vuex comparison page. If you're evaluating state management solutions for a new Vue project, check the Pinia package page for current release cadence. For Vuex migration documentation, the Vuex package page tracks the current maintenance status.
See the live comparison
View pinia vs. vuex on PkgPulse →