pinia
Pinia is the official application-state store for Vue 3. A store groups reactive state, computed getters, and actions behind a typed `use...Store()` function, while Vue Devtools records state changes and actions. You can define stores with an Options-style object or a Composition API setup function, split them by domain, call them from components or routing code, extend them with plugins, and hydrate them during server rendering. It replaces Vuex for new Vue applications without Vuex's mutations or nested module ceremony.
Pinia is the default state-store choice for a current Vue application, and its simple API stays out of the way. Install it only for genuinely shared client state, and budget for ESM, required peers, persistence choices, and SSR isolation rather than assuming those details are automatic.
Use it if
- You have state shared across unrelated Vue components and prop drilling or ad hoc module refs have become hard to trace
- You want Vue-native reactivity, first-class TypeScript inference, Vue Devtools inspection, and hot-module replacement in one small store layer
- You are starting or modernizing a Vue 3.5 application and want the state library recommended by the Vue documentation
- You need domain stores that can call one another without maintaining a nested module tree
- Your state belongs to one component subtree: props, emits, provide/inject, or a composable usually keeps ownership clearer than a global store
- You are on Vue 2 or Vue earlier than 3.5.11: Pinia 4's peer range is Vue ^3.5.11, and the README sends Vue 2 users to the older v2 branch
- Your build still consumes CommonJS: the Pinia 4 release notes say the package is ESM-only, so legacy Jest, require(), or older bundler setups need migration
- You want an event-driven statechart with explicit impossible states and visualized transitions: Pinia actions are ordinary methods, not a state-machine model
- You expect persistence, cross-tab synchronization, undo history, or server-cache deduplication out of the box; Pinia core provides none of those, so each needs a plugin or separate data-fetching layer
Setup reality
For Pinia 4, install both `pinia` and `@vue/devtools-api`; the latter is a required peer dependency, not an optional dev-only convenience. The peer range also requires Vue ^3.5.11, and TypeScript users need TypeScript 5.6 or newer. Pinia 4 is ESM-only. Create exactly one `createPinia()` instance for a normal SPA and register it with `app.use(pinia)` before any component calls a store. Calls made outside component setup, such as router guards or API modules, must run after installation or receive the pinia instance explicitly. Destructuring state directly breaks reactivity; use `storeToRefs()`, while actions can be destructured normally. Setup stores must return every state ref or SSR, Devtools, and plugins can break, and unlike Option stores they do not get a built-in `$reset()`. Persistence is not built in, so either subscribe and serialize carefully or vet a plugin. SSR adds the sharpest edge: create a fresh Pinia per request, serialize `pinia.state.value` with an XSS-safe serializer such as devalue, and activate the request's instance before calling stores outside setup. Nuxt users should use the matching `@pinia/nuxt` module rather than hand-rolling hydration. Tests need `setActivePinia(createPinia())` for unit work or the separate `@pinia/testing` package; its default behavior stubs actions, which can make a test pass without executing business logic unless `stubActions: false` is set.
Patterns
Install one Pinia root in a Vue appinstall-plugin
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.mount('#app');Pinia 4 also requires `@vue/devtools-api` to be installed. Register Pinia before any mounted component calls a store.
Define a typed Option storedefine-option-store
import { defineStore } from 'pinia';
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as { id: string; price: number }[] }),
getters: {
total: (state) => state.items.reduce((sum, item) => sum + item.price, 0),
},
actions: {
add(item: { id: string; price: number }) {
this.items.push(item);
},
},
});The id must be unique across the application because Pinia uses it for registration, Devtools, and hydration.
Define a Composition API storedefine-setup-store
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() { count.value += 1; }
function reset() { count.value = 0; }
return { count, doubled, increment, reset };
});Return every state ref. Hidden or readonly state can break SSR, Devtools, and plugins; setup stores also need their own reset action.
Destructure store state without losing reactivitykeep-destructured-state-reactive
<script setup lang="ts">
import { storeToRefs } from 'pinia';
import { useCartStore } from '@/stores/cart';
const cart = useCartStore();
const { items, total } = storeToRefs(cart);
const { add } = cart;
</script>Use storeToRefs for state, getters, and reactive plugin properties. Actions are already bound and can be destructured directly.
Apply related state changes as one patchpatch-state
const cart = useCartStore();
cart.$patch((state) => {
state.items.push({ id: 'book', price: 20 });
state.coupon = 'READ20';
});The function form is preferable for collection mutations. It groups the changes into one Devtools entry and one subscription event.
Reset an Option storereset-option-store
const cart = useCartStore();
cart.$reset();$reset recreates an Option store's initial state. Setup stores do not provide it automatically; define and expose a reset action there.
Put an async request in an actionrun-async-action
export const useUserStore = defineStore('user', {
state: () => ({ profile: null as User | null, loading: false }),
actions: {
async load(id: string) {
this.loading = true;
try {
this.profile = await api.getUser(id);
} finally {
this.loading = false;
}
},
},
});Actions may be async and can call other actions or stores. Pinia does not deduplicate requests or cache server data automatically.
Persist selected state changessubscribe-to-state
const cart = useCartStore();
const stop = cart.$subscribe((_mutation, state) => {
localStorage.setItem('cart', JSON.stringify({ items: state.items }));
}, { detached: true });
// call stop() when this application-level subscription is no longer neededdetached keeps the subscription alive beyond the creating component. Validate and version persisted data before hydrating it in a real application.
Record action outcomesobserve-actions
const user = useUserStore();
const stop = user.$onAction(({ name, args, after, onError }) => {
const started = performance.now();
after(() => console.log(name, args, performance.now() - started));
onError((error) => console.error(name, error));
}, true);The second argument detaches the listener from the current component lifecycle. Avoid logging secrets carried in action arguments.
Use a store in a router guarduse-store-outside-component
import { createPinia } from 'pinia';
import { createApp } from 'vue';
export const pinia = createPinia();
createApp(App).use(pinia).use(router).mount('#app');
router.beforeEach((to) => {
const auth = useAuthStore(pinia);
if (to.meta.requiresAuth && !auth.user) return '/login';
});Passing the instance makes ordering explicit. In SSR, never share this module-level root across requests; create one per request instead.
Add a property with a Pinia pluginextend-with-plugin
import { markRaw } from 'vue';
const pinia = createPinia();
pinia.use(({ store }) => {
store.router = markRaw(router);
});
app.use(pinia);Plugins apply only to stores created after the plugin is installed and after Pinia is registered on an app. Add a PiniaCustomProperties declaration for TypeScript.
Unit-test a store with a fresh roottest-store
import { beforeEach, expect, it } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
beforeEach(() => setActivePinia(createPinia()));
it('increments', () => {
const counter = useCounterStore();
counter.increment();
expect(counter.count).toBe(1);
});A fresh active Pinia prevents state leaking between tests. Component tests using @pinia/testing stub actions by default unless stubActions is false.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| vuex | npm | Keep it in an established Vuex application where migration cost outweighs removing mutations and modules |
| nanostores | npm | Choose it for tiny framework-agnostic stores shared across Vue, React, Svelte, or vanilla islands |
| @xstate/vue | npm | Choose it when workflows need explicit states, guarded transitions, cancellation, and statechart tooling |
| @vueuse/core | npm | Choose composables alone when the shared concern is narrow and does not need a centralized store or Devtools timeline |