Pinia (and Where Vuex Still Appears)
Pinia is the Vue state-management library (officially recommended). Three primitives — state, getters, actions — exposed as composable-style stores. No mutations, no namespacing pain, full TS inference, dev-tools support, SSR-friendly. Vuex is the legacy library; Vuex 4 supports Vue 3 but everyone’s migrating. Senior topics: setup vs options stores, server-state separation (use TanStack Query / VueQuery for that), HMR-friendly store design, and avoiding the “everything in the store” anti-pattern.
Show a Pinia store.
Two equivalent shapes:
Setup store (preferred)
// stores/counter.ts
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useCounterStore = defineStore("counter", () => {
// state
const count = ref(0);
// getter
const double = computed(() => count.value * 2);
// action
function increment() { count.value++; }
async function loadFromServer() {
count.value = await fetch("/api/count").then(r => r.json());
}
return { count, double, increment, loadFromServer };
});Options store (Vuex-ish, occasionally clearer)
export const useCounterStore = defineStore("counter", {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() { this.count++; },
async loadFromServer() {
this.count = await fetch("/api/count").then(r => r.json());
},
},
});Consuming it anywhere
<script setup>
import { useCounterStore } from "@/stores/counter";
import { storeToRefs } from "pinia";
const store = useCounterStore();
const { count, double } = storeToRefs(store); // destructure keeping reactivity
store.increment();
</script>Why storeToRefs?
Same destructuring problem as plain reactive — direct destructure loses reactivity. storeToRefs(store) wraps state + getters in refs so destructuring stays reactive. Actions don’t need it (they’re methods).
const { count, double, increment } = storeToRefs(store);
// count, double — reactive; increment — undefined!
// Correct:
const store = useCounterStore();
const { count, double } = storeToRefs(store);
const { increment } = store;Pinia vs Vuex — what changed?
| Vuex | Pinia | |
|---|---|---|
| Stores | one big store with namespaced modules | many small stores; no namespacing needed |
| State mutation | commit("mutation", payload) — verbose ceremony |
direct assignment in actions |
| Mutations layer | required (commit) |
gone — actions mutate directly |
| Actions | dispatch("action") strings |
typed function calls |
| TypeScript | painful (typed modules, types-strict mode) | native — types inferred from store |
| Devtools | yes | yes (better integration in Vue 3) |
| Boilerplate | a lot | minimal |
| SSR | manual | built-in |
The single biggest win is no mutations. Vuex separated “synchronous state changes” (mutations) from “logic” (actions) and required dispatching through strings; the indirection bought debuggability at the cost of ergonomics. Pinia’s actions mutate directly; dev tools still record every change.
When to use Pinia and when not to.
Use Pinia for:
- Cross-component client state that doesn’t fit one component (auth user, theme, cart, notifications panel).
- Logic with actions (login flow, cart manipulation) that’s reusable.
- State you want in dev tools (time-travel debug, snapshots).
Don’t use Pinia for:
- Server data — that’s TanStack Query (Vue Query) territory. Cache, dedup, invalidate, refetch — Pinia doesn’t model any of this. Putting server data in Pinia recreates problems React Query solved.
- Component-local state —
refinside the component is fine; Pinia is overkill. - One-shot deep prop drilling —
provide/injectis lighter.
The senior framing: server state ≠ client state. Pinia owns client state; server-state caches (Vue Query / SWR) own server state. They’re complementary, not alternatives.
Cross-store usage.
A store can use another store. Just call the composable:
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
// works — composable inside composable
const user = useUserStore();
async function checkout() {
if (!user.isAuthenticated) throw new Error("login required");
// ...
}
return { items, checkout };
});Don’t try to cross-store at module top-level (Pinia isn’t initialized yet). Inside actions/getters is fine.
SSR / Nuxt with Pinia.
Nuxt has a @pinia/nuxt module. The pattern:
- Stores work the same.
- On the server, each request gets a fresh Pinia instance.
- State is serialized into the rendered HTML and rehydrated on the client.
- Stores called during
setupof anasyncData/useFetchblock populate before render.
In bare Vue 3 SSR, you create a Pinia per request:
import { createPinia } from "pinia";
export function createApp() {
const app = createSSRApp(App);
const pinia = createPinia();
app.use(pinia);
return { app, pinia };
}Serialize pinia.state.value after SSR, send to client, rehydrate.
Persistence.
Not built-in; use pinia-plugin-persistedstate:
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// in a store:
export const useUserStore = defineStore("user", () => { ... }, {
persist: true, // persist to localStorage
});Common for theme preference, draft form data, “remember me” tokens. Don’t persist sensitive data (tokens better in httpOnly cookies — see Frontend Security — Senior Interview Prep).
Testing Pinia stores.
Mount with a test Pinia in @vue/test-utils:
import { setActivePinia, createPinia } from "pinia";
import { beforeEach, describe, it, expect } from "vitest";
import { useCounterStore } from "@/stores/counter";
describe("counter store", () => {
beforeEach(() => { setActivePinia(createPinia()); });
it("increments", () => {
const store = useCounterStore();
expect(store.count).toBe(0);
store.increment();
expect(store.count).toBe(1);
});
});setActivePinia between tests resets state. For component tests, mount with global.plugins: [createPinia()].
Hot module reload (HMR) considerations.
Stores accept HMR if you opt in:
import { acceptHMRUpdate } from "pinia";
export const useCounterStore = defineStore("counter", () => { ... });
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot));
}Otherwise editing a store reloads the whole page (and loses state). For dev productivity on big apps, add to every store via a snippet.
Gotchas
- Destructuring without
storeToRefssilently loses reactivity — the classic Pinia bug. - Calling
useStore()outsidesetupor before Pinia is installed errors with “no active Pinia.” Common when importing a store at module top-level for one-off uses. - Server data in Pinia — looks like it works, then you reinvent cache/invalidate/stale-while-revalidate. Use Vue Query / TanStack Query.
- Circular store imports —
useStoreA→ usesuseStoreB→ usesuseStoreAwill deadlock at init. Restructure into a third coordinating store, or pass data via parameters. $reset()works on options stores; setup stores need a custom reset action because Pinia can’t infer initial state from a function.- Persistent storage of refs —
pinia-plugin-persistedstateserializes via JSON, so non-JSON-safe values (Maps, Sets, Dates) need custom serializers.
Related
Further reading
- Pinia docs: https://pinia.vuejs.org/
- Pinia vs Vuex comparison: https://pinia.vuejs.org/introduction.html#comparison-with-vuex
- Vue Query (TanStack Query for Vue): https://tanstack.com/query/latest/docs/framework/vue/overview
Interview angle 6
- “What replaced Vuex, and what changed?” - Pinia, and the biggest change is that the mutations layer is gone. Actions mutate state directly and devtools still records every change, which removes an entire ceremony that existed only to make changes traceable.
- “Setup store or options store?” - setup style is the modern default, because it is just Composition API and composes with everything else you already write. The options style exists mainly to make a Vuex migration mechanical.
- “Should server data live in Pinia?” - no. Put it in TanStack Query for Vue and let Pinia own client state. Caching server responses in a store means reinventing deduplication, invalidation and refetch-on-focus by hand, badly.
- “You destructured a store and reactivity broke.” -
storeToRefsfor state and getters, which converts each to arefthat stays connected. Actions are plain functions and can be pulled off the store directly. - “How does Pinia work with SSR?” - a fresh store per request, or every user shares one server-side and sees each other’s data. State is serialised into the HTML and rehydrated on the client. Nuxt’s module handles this; bare Vue SSR you wire yourself.
- “What about persistence?” - a plugin writing to storage, and never for
tokens. Anything a script can read is exposed to XSS, so credentials belong in
an
HttpOnlycookie rather than a persisted store.