frontend / vue / 10_pinia.md

Pinia (and Where Vuex Still Appears)

6 min read source

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 stateref inside the component is fine; Pinia is overkill.
  • One-shot deep prop drillingprovide/inject is 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 setup of an asyncData/useFetch block 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 storeToRefs silently loses reactivity — the classic Pinia bug.
  • Calling useStore() outside setup or 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 importsuseStoreA → uses useStoreB → uses useStoreA will 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 refspinia-plugin-persistedstate serializes 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 storeToRefs to 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

Further reading