Installing Pinia: Why app.use(pinia) Must Happen Before Anything Calls a Store
Pinia starts as a plain plugin object, and the single most common first bug is using a store before the plugin is installed. The installation sequence is small enough to memorize:
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia() // createPinia returns the plugin AND the instance
app.use(pinia) // register it
app.mount('#app') // only now can any component call a store
createPinia() does two jobs at once: it returns the object you pass to app.use() (the plugin contract), and it is the active Pinia instance that every useStore() call will bind to. Keep the return value — later topics and most SSR setups need that exact instance. Notice the order: app.use(pinia) before app.mount(). Components run their setup functions during mount, and a setup function that calls useStore() with no active Pinia throws an error like "no active Pinia". The same trap catches you in router guards: if you define a navigation guard in Navigation Guards that reads a user store, the guard must run after the app (and pinia) exist.
Where the instance is created decides who can see it:
| Scenario | Where createPinia() runs |
Lifetime |
|---|---|---|
| Client SPA | main.ts, module scope |
One instance for the page's life |
| SPA + router guards | main.ts, kept in a variable |
Shared with the router at startup |
| SSR / server rendering | Inside the per-request handler | One instance per request |
| Unit test | beforeEach(() => setActivePinia(createPinia())) |
One instance per test case |
The SSR row is the one that bites in production: a pinia created at module scope on a server is shared by every request, so user A's session state renders in user B's HTML. Pinia is intentionally framework-agnostic under the hood, which is why the instance works with or without an app attached — that property is exactly what makes per-request instances possible.
Pinia itself installs with npm install pinia (or pnpm add pinia). Vue must already be present, and the Vue Devtools browser extension provides the inspection panel later — there is no extra devtools package to add for Vue 3. Pinia ships an ESM build, so import { createPinia } from 'pinia' works in Vite with zero configuration.
Two failure modes are worth separating. If you skip app.use(pinia) entirely, every store call fails immediately with the "no active Pinia" error — a loud failure and a one-line fix. The quieter failure is calling createPinia() twice and installing both: the second install wins the active slot, stores constructed against the first instance silently diverge from what components see, and you spend an afternoon chasing state that "resets itself". One app, one createPinia(), one app.use() is the rule that prevents both.
A quick way to verify the install before you write any store: create a throwaway store, call it from main.ts right after app.use(pinia), and log one field. If that prints, every component will resolve stores too — the failure surface is install order, not store code. Note also that app.use(pinia) registers Pinia through Vue's plugin system, so the instance ends up in the app's inject-provided context. That is why a bare useStore() inside a component finds it: nothing is imported on demand and nothing is passed down as a prop; the call resolves through the same provide/inject channel Vue uses for the router and the i18n locale.
Options Store or Setup Store: Two Syntaxes Behind One defineStore
defineStore(id, options) accepts either a classic options object or a setup function. Both produce the same kind of thing — a callable store — but they organize your code differently, and the setup style is where Pinia is heading.
The options syntax mirrors the Options API shape you may already know from components:
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, lastUpdated: null as number | null }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
Three rules to internalize. First, state must be a factory function returning a fresh object — the same reason data() is a function in Options API components: a shared literal object would let one instantiation's mutations leak into another's, and it keeps initial state correct when Vite hot-reloads the module. Second, the first argument to defineStore is the store's id, a string you choose once; devtools lists stores under this id, and in SSR it keys the serialized state, so treat it as a stable identifier rather than a display name. Third, getters receive state as their parameter, and actions are methods with this bound to the store instance.
The setup syntax replaces the three sections with one function that looks exactly like a component's <script setup>:
// stores/counter.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const lastUpdated = ref<number | null>(null)
const double = computed(() => count.value * 2)
function increment() {
count.value++
lastUpdated.value = Date.now()
}
return { count, lastUpdated, double, increment }
})
Pinia inspects what you return and classifies each export automatically: refs become state fields, computeds become getters, plain functions become actions. Nothing is decorated or registered — classification happens once when the store is first instantiated. A side effect of the setup style is full TypeScript inference without helper types, and the freedom to compose: you can call other composables (useRoute, a custom useApi) inside the setup function, which an options store can express only awkwardly through getters referencing imported helpers.
| Concern | Options store | Setup store |
|---|---|---|
| Shape | state / getters / actions sections |
one function, returns everything |
| Access to state in getters | state param or this |
closure over refs |
| TypeScript inference | needs care with this |
inferred directly from returns |
| Composing other composables | not directly | call them inside the setup body |
| Private fields | not expressible | any local you do not return |
A trap specific to the setup style: local variables you do not return are private to the store closure. A const internal = ref(0) that never reaches the return object is invisible to devtools and to $state — useful for genuinely private data, a mystery bug when accidental. Whatever you want visible in the timeline or patchable from outside must be in that return object.
For the rest of this roadmap we use the setup syntax unless the example is clearer in options form. If an interview asks you to compare them, the honest summary is: options is declarative and familiar to Options API users, setup is composable and type-friendly, and both compile to the same reactive store machinery underneath.
Calling the Store Function: One Singleton, Inside Components and Out
defineStore returns a function — useCounterStore is not the store, it is the way you get the store. Calling it with no arguments returns the singleton instance registered under that id:
const store = useCounterStore() // instance #1
const same = useCounterStore() // the same object, by reference
console.log(store === same) // true
console.log(store.$id) // 'counter' — the id you passed to defineStore
The id lives inside defineStore('counter', ...), so the call takes no parameters. The first call instantiates the store and registers it in the active pinia; every later call — from another component, from an action, from a test — returns that same instance. That is the entire mechanism of shared state in Pinia: two components that both call useCounterStore() read one object, so a mutation in one is visible in the other with no event bus, no provide/inject, and no prop drilling.
Because the store binds to the active pinia rather than to a component, it works anywhere. That is a deliberate design and a frequent interview point:
// lib/analytics.ts — plain module, no component in sight
import { useSessionStore } from '../stores/session'
export function track(event: string) {
const session = useSessionStore() // fine, as long as a pinia is active
return fetch('/api/track', {
method: 'POST',
body: JSON.stringify({ event, userId: session.userId }),
})
}
The catch is the phrase as long as a pinia is active. In a browser app, installing pinia in main.ts makes it active globally for that page, so module-level code is fine once the app has booted. In a test or a Node script there is no app, so you activate one yourself — this preamble is the standard Vitest setup for store tests:
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'
beforeEach(() => {
setActivePinia(createPinia()) // fresh, empty store for every test
})
test('increment adds one', () => {
const store = useCounterStore()
store.increment()
expect(store.count).toBe(1)
})
A fresh pinia per test is what keeps state from one case leaking into the next — a single pinia created at module scope would carry mutations across the whole suite, producing failures that look like ordering bugs but are really just shared state. The same singleton rule also explains why you must not cache the store instance across environments: hold a reference in a module variable on the server and every request reuses it, which is state leakage between users.
Two smaller consequences deserve mention. On Vite hot reload, calling useCounterStore() after the store module changed still resolves to the existing instance with the same $id, so components do not lose their data mid-edit; changing the id, by contrast, deliberately creates a brand-new empty store. And because the store is an ordinary object, nothing stops you from mutating its refs from anywhere — Pinia does not enforce that changes go through actions, but it records where each mutation happened, which is what makes the discipline in State and Getters worth adopting as you start reading and patching fields.
One nuance survives into production: a call made inside a setTimeout or an event listener that outlives the request can run after that request's pinia was disposed, so SSR code should capture the store reference during setup instead of calling useStore() again inside the callback. The same reasoning applies to watchers — call the store once at setup top level, then watch the fields you received from it. One lookup, one subscription, no surprises: every reactive connection in the component traces back to a single instance you already hold in a local variable, and no callback can ever resolve a stale or missing one.
Quiz
1. A component calls a store during setup and Pinia throws "no active Pinia". Which fix is correct?
2. In a setup-style store, what does Pinia infer from the returned values `const count = ref(0)`, `const label = computed(() => 'n=' + count.value)`, and `function reset() { count.value = 0 }`?
3. What happens when three different components call useCounterStore() in the same running app?
4. You are writing a Vitest unit test for a store action. What must the test do before first calling the store function?
Flashcards
Question
What does createPinia() return, and where must it be installed relative to app.mount()?
Click to reveal answer
Answer
It returns the Pinia plugin object, which is also the instance every useStore() call binds to. Install it with app.use(pinia) before app.mount(), because component setup functions run during mount and would otherwise call stores with no active Pinia.
Question
How does Pinia classify the return values of a setup-function store?
Click to reveal answer
Answer
By their runtime type: refs become state fields, computed refs become getters, and plain functions become actions. Values not returned from the setup function stay private to the store closure and never appear in $state or devtools.
Question
Why must an options store's state option be a function, `state: () => ({ ... })`?
Click to reveal answer
Answer
It must return a fresh object so each instantiation of the store — including re-instantiation during hot module replacement — gets its own state rather than sharing one literal object. This mirrors why an Options API component's data() is a function.
Question
Is a store usable outside components? Name the one precondition.
Click to reveal answer
Answer
Yes — a store is bound to the active pinia, not to a component, so plain modules, router guards, and Node scripts can all call it. The precondition is that a pinia instance is active: in the browser that is the one installed on the app; in tests you call setActivePinia(createPinia()) first.
Question
Why call setActivePinia(createPinia()) in beforeEach instead of once at the top of the test file?
Click to reveal answer
Answer
A store instance persists in the pinia it was created in. Creating one pinia per file would let mutations from one test carry into the next, producing order-dependent failures; a fresh pinia per test resets every store to its initial state.
Revision Notes
Key Takeaways
- 1. createPinia() returns both the plugin for app.use() and the instance that useStore() binds to — install it before mount or setup calls fail with "no active Pinia"
- 2. A setup store classifies its returns automatically: ref → state, computed → getter, function → action; unreturned locals are private
- 3. state in the options syntax must be a factory function returning a fresh object, protecting HMR and initial-state semantics
- 4. The store function is a singleton lookup keyed by id — every caller shares one object, which is the whole sharing mechanism
- 5. Stores run outside components as long as a pinia is active; tests create one with setActivePinia(createPinia()) per test case
Interview Tips
- • "What does createPinia() return?" — the plugin you install and the instance stores bind to; mention keeping the reference because SSR creates one per request and the router may need it before mount
- • "Options store vs setup store?" — same runtime machinery; options is declarative with explicit state/getters/actions, setup gives closure composition, better TS inference, and can call other composables
- • "Why is my store shared across components?" — useStore() resolves a singleton registered under defineStore's id in the active pinia, not per-component state
- • "How do you unit test a store?" — setActivePinia(createPinia()) in beforeEach, call the store, act, assert state; no component mount needed because stores are not tied to components
Cheat Sheet
Creating Pinia Stores
Install order (browser):
const app = createApp(App)
const pinia = createPinia() // keep the instance: SSR and router need it
app.use(pinia) // before anything calls a store
app.mount('#app')
defineStore shapes:
| Style | Declaration | Becomes |
|---|---|---|
| Options | state: () => ({...}), getters, actions |
fields / getters / methods with this |
| Setup | refs, computeds, functions, all returned |
state / getters / actions, auto-classified |
Singleton rule: useCounterStore() returns the same instance on every call, keyed by the id passed to defineStore (store.$id). Works in plain modules and router guards whenever a pinia is active.
Instance lifetime: SPA → one per page; SSR → one per request; tests → one per test via beforeEach(() => setActivePinia(createPinia())).