Pinia (and Where Vuex Still Appears)
TL;DR
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.
Interview Q&A
Q: Show a Pinia store.
A: Two equivalent shapes:
Setup store (preferred — Composition API style):
// 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());
},
},
});
Consume 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>
Q: Why storeToRefs?
A: 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;
Q: Pinia vs Vuex — what changed?
A:
| 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.
Q: When to use Pinia and when not to.
A:
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.
Q: Cross-store usage.
A: A store can use another store. Just call the composable:
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
const user = useUserStore(); // works — composable inside composable
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.
Q: SSR / Nuxt with Pinia.
A: 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.
Q: Persistence.
A: 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 ../17_security/).
Q: Testing Pinia stores.
A: 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()].
Q: Hot module reload (HMR) considerations.
A: 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 / edge cases
- 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.
What a senior is expected to say
- “Pinia is the recommended Vue store. Setup-store style (Composition API) is the modern default; Options-store style mirrors Vuex for migration.”
- “No mutations layer — actions mutate state directly, dev tools record every change. That’s the biggest ergonomic win over Vuex.”
- “Server state belongs in TanStack/Vue Query, not Pinia. Pinia owns client state; mixing causes you to reinvent cache, dedup, invalidation.”
- “Destructure with
storeToRefsto keep reactivity; pull actions directly off the store.” - “For SSR, fresh Pinia per request; state serialized into HTML and rehydrated. Nuxt’s module handles this; bare Vue SSR you wire by hand.”
- “Persistence via plugin; never persist sensitive tokens — those belong in httpOnly cookies.”
Cross-references
- Server state (the half Pinia doesn’t own): ../11_apis_data_fetching/02_tanstack_query.md
- Provide/inject vs Pinia: 09_provide_inject.md
- Composition API: 05_composition_vs_options.md
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