Pinia Source Analysis: defineStore Internals and Reactive Store Creation
Pinia's lack of mutations is not a design trade-off — it's a deliberate engineering position: in Vue 3, mutations never existed to "modify state." They existed to "let devtools know which modifications happened." Vue 3's reactivity system already tracks all changes at the foundation; Pinia only needs to give devtools an appropriate interception point. This understanding overturns your notion of why state management "requires so much ceremony."
Level 1 · What You Need to Know (1–3 Years Experience)
Two Ways to Define a Store
Options Store (similar to Vuex, structured):
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
id: 0,
name: '',
role: 'viewer' as 'admin' | 'editor' | 'viewer',
permissions: [] as string[]
}),
getters: {
isAdmin: (state) => state.role === 'admin',
displayName: (state) => state.name || 'Guest',
canEdit: (state) => state.role !== 'viewer'
},
actions: {
async login(username: string, password: string) {
const user = await api.login(username, password)
this.id = user.id
this.name = user.name
this.role = user.role
this.permissions = user.permissions
},
logout() {
this.$reset()
}
}
})
Setup Store (flexible, Composable-style):
export const useUserStore = defineStore('user', () => {
// state: ref/reactive
const id = ref(0)
const name = ref('')
const role = ref<'admin' | 'editor' | 'viewer'>('viewer')
const permissions = ref<string[]>([])
// getters: computed
const isAdmin = computed(() => role.value === 'admin')
const displayName = computed(() => name.value || 'Guest')
// actions: plain functions
async function login(username: string, password: string) {
const user = await api.login(username, password)
id.value = user.id
name.value = user.name
role.value = user.role
permissions.value = user.permissions
}
function logout() {
id.value = 0
name.value = ''
role.value = 'viewer'
permissions.value = []
}
// Must return explicitly (same as Composables)
return { id, name, role, permissions, isAdmin, displayName, login, logout }
})
Using Stores in Components
<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const store = useUserStore()
// Method 1: direct access (works for actions, no reactivity loss)
store.login(username, password)
// Method 2: storeToRefs destructuring (for state/getters)
const { name, isAdmin, displayName } = storeToRefs(store)
// name, isAdmin are Refs — maintain reactivity after destructuring
// ❌ Direct destructuring of state/getters loses reactivity
const { name, isAdmin } = store // plain values, not reactive
</script>
$patch: Batch Updates
const store = useUserStore()
// Object form: shallow merge
store.$patch({ name: 'Alice', role: 'admin' })
// Function form: allows complex operations (recommended for array mutations)
store.$patch((state) => {
state.permissions.push('write')
state.permissions = state.permissions.filter(p => p !== 'read')
})
The function form's advantage: all modifications complete in a single $patch call, recorded as one operation in devtools.
$reset(), $subscribe(), $onAction()
// Reset to initial state (Options Store only)
store.$reset()
// Listen to state changes
const unsubscribe = store.$subscribe((mutation, state) => {
console.log(mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log(mutation.storeId) // 'user'
// Persistence logic
localStorage.setItem('user', JSON.stringify(state))
})
unsubscribe() // remove listener
// Listen to action calls
store.$onAction(({ name, args, after, onError }) => {
console.log(`Action ${name} called with`, args)
after((result) => console.log('Action succeeded:', result))
onError((error) => console.error('Action failed:', error))
})
Level 2 · How It Actually Works (3–5 Years Experience)
The Internal Mechanism of defineStore
defineStore returns a Composable function (useStore), not an immediately created store. Each time this function is called:
useUserStore() called
│
▼
Get current pinia instance
(via inject(piniaSymbol) from appContext)
│
▼
Check pinia._s (Map<id, store>)
Does id='user' already exist?
├── YES → return cached store instance directly
└── NO → create new store instance
│
├─ Options Store: createOptionsStore()
└─ Setup Store: createSetupStore()
Singleton mechanism: pinia._s is a Map, key is store id, value is store instance. The same id is only created once. This is why calling useUserStore() from different components returns the same object.
How Options Store is Internally Converted
Options Store is converted into a Setup Store internally:
// Simplified createOptionsStore implementation
function createOptionsStore(id, options, pinia) {
const { state, actions, getters } = options
const initialState = state?.() ?? {}
// Convert to setup function
function setup() {
// 1. state → reactive
const storeState = pinia.state.value[id] = reactive(initialState)
// 2. getters → computed
const storeGetters = {}
for (const name in getters) {
storeGetters[name] = computed(() => {
return getters[name].call(store, storeState)
})
}
// 3. actions → functions with bound this
const storeActions = {}
for (const name in actions) {
storeActions[name] = function(...args) {
return actions[name].apply(store, args)
}
}
return { ...storeState, ...storeGetters, ...storeActions }
}
return createSetupStore(id, setup, pinia)
}
The Core Store Creation Process
// Simplified createSetupStore
function createSetupStore(id, setup, pinia) {
// Use effectScope to manage all reactive side effects
const scope = effectScope(true) // detached
// Run setup inside scope, capturing all created effects
const setupStore = scope.run(() => setup())
// Build store object
const store = reactive({
_id: id,
_scope: scope,
$patch: patchMethod,
$subscribe: subscribeMethod,
$onAction: onActionMethod,
$dispose: () => scope.stop(), // cleanup all side effects
...setupStore
})
// Register in pinia._s
pinia._s.set(id, store)
return store
}
effectScope: The Store's Lifecycle Container
app.provide(piniaSymbol, pinia)
│
▼
useUserStore() first call
│
▼
scope = effectScope(true) ← detached, not bound to any component
│
▼
scope.run(() => {
// All ref, computed, watch created in setup
// are registered in scope's effect list
const id = ref(0) ← registered to scope
const isAdmin = computed(...) ← registered to scope
return { id, isAdmin, ... }
})
│
▼
store._scope = scope
// store is NOT destroyed when using components unmount
// Only explicit store.$dispose() destroys it
Why use detached effectScope: A store's lifecycle is independent from components. If the store were bound to a component's effectScope, it would be destroyed when that component unmounted — wrong, because other components might still be using the same store.
$patch Implementation Mechanism
// Two $patch forms
function $patch(partialStateOrMutator) {
// Pause triggering watchers during batch update
isListening = false
isSyncListening = false
if (isFunction(partialStateOrMutator)) {
// Function form: pass store to user function
partialStateOrMutator(store)
} else {
// Object form: shallow merge
mergeReactiveObjects(store, partialStateOrMutator)
}
// Restore listening
isListening = true
isSyncListening = true
// Trigger subscription notifications once
triggerSubscriptions(
subscriptions,
{ type: isFunction(partialStateOrMutator)
? MutationType.patchFunction
: MutationType.patchObject,
storeId: $id,
},
store
)
}
Key: $patch pauses watcher triggers during modifications, then triggers a single unified notification after all modifications complete, avoiding intermediate-state watcher calls.
Store Creation and Access Flow Diagram
App initialization:
createPinia() → pinia instance
├─ pinia.state = ref({}) ← container for all store states
├─ pinia._s = new Map() ← store instance cache
└─ pinia._e = effectScope() ← root effectScope
app.use(pinia)
└─ app.provide(piniaSymbol, pinia)
useUserStore() in a component:
inject(piniaSymbol) → pinia
pinia._s.has('user')? → NO
createSetupStore('user', setup, pinia)
├─ scope = effectScope(true)
├─ setupStore = scope.run(setup)
├─ store = reactive({ ...setupStore, $patch, ... })
└─ pinia._s.set('user', store)
→ return store
useUserStore() called again in another component:
inject(piniaSymbol) → pinia
pinia._s.has('user')? → YES
→ return pinia._s.get('user') directly (same instance)
Level 3 · Design Documents and Source Code (Senior Developers)
Pinia's Core Data Structures
// packages/pinia/src/createPinia.ts
export interface Pinia {
install: (app: App) => void
use: (plugin: PiniaPlugin) => Pinia
_p: PiniaPlugin[] // plugin list
_a: App // bound app instance
_e: EffectScope // root effectScope
_s: Map<string, StoreGeneric> // store cache Map
state: Ref<Record<string, StateTree>> // all store states container
}
pinia.state is the root container for all store states — this enables SSR hydration and state snapshots:
// SSR: generate state snapshot on server
const pinia = createPinia()
// ...handle request
const snapshot = JSON.stringify(pinia.state.value)
// Client: inject snapshot to avoid re-fetching
const pinia = createPinia()
pinia.state.value = JSON.parse(snapshot)
$onAction Interception Mechanism
$onAction wraps all actions when the store is created:
// packages/pinia/src/store.ts (simplified)
for (const actionName in setupStore) {
if (typeof setupStore[actionName] === 'function') {
setupStore[actionName] = wrapAction(actionName, setupStore[actionName])
}
}
function wrapAction(name, action) {
return function(...args) {
const afterCallbackList: ((resolvedReturn: any) => void)[] = []
const onErrorCallbackList: ((error: unknown) => void)[] = []
// Notify all $onAction subscribers
triggerSubscriptions(actionSubscriptions, {
args, name, store,
after: (cb) => afterCallbackList.push(cb),
onError: (cb) => onErrorCallbackList.push(cb),
})
let ret: unknown
try {
ret = action.apply(store, args)
} catch (error) {
triggerSubscriptions(onErrorCallbackList, error)
throw error
}
if (ret instanceof Promise) {
return ret
.then((value) => {
triggerSubscriptions(afterCallbackList, value)
return value
})
.catch((error) => {
triggerSubscriptions(onErrorCallbackList, error)
return Promise.reject(error)
})
}
triggerSubscriptions(afterCallbackList, ret)
return ret
}
}
This wrapping mechanism ensures $onAction's after and onError callbacks work correctly for both synchronous and asynchronous actions.
The storeToRefs Implementation
// Simplified storeToRefs
export function storeToRefs<SS extends StoreGeneric>(store: SS) {
const refs: Record<string, Ref> = {}
for (const key in store) {
const value = store[key]
if (isRef(value) || isReactive(value)) {
// state and getters: convert to ref
refs[key] = toRef(store, key)
}
// actions are plain functions — skip (don't need ref)
}
return refs
}
toRef(store, key) creates a computed reference that reads from store[key] and writes to store[key]. The destructured ref always stays in sync with store state.
Pinia Plugin System
// Plugin interface: called when each store is created
export interface PiniaPlugin {
(context: PiniaPluginContext): Partial<PiniaCustomProperties> | void
}
// Example: persistence plugin
function persistPlugin(context: PiniaPluginContext) {
const { store } = context
// Restore state from localStorage
const saved = localStorage.getItem(store.$id)
if (saved) {
store.$patch(JSON.parse(saved))
}
// Save on changes
store.$subscribe(() => {
localStorage.setItem(store.$id, JSON.stringify(store.$state))
})
}
pinia.use(persistPlugin)
Plugins are called immediately after each store is created, with access to the complete store instance — they can add properties, listen to changes, wrap actions, and more.
HMR Support
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
return { count }
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}
On HMR, Pinia replaces the cached store with the new definition while preserving current state (avoiding state loss during development).
Level 4 · Edge Cases and Pitfalls (All Levels)
Pitfall 1: Using Stores Outside setup Context
Pinia stores use inject(piniaSymbol) to get the pinia instance, which depends on Vue's dependency injection system:
// ❌ Using store outside component setup (e.g., in route guards, axios interceptors)
router.beforeEach((to) => {
const store = useUserStore() // Error: no active Vue app context
})
// ✅ Solution 1: pass pinia instance explicitly
import { createPinia } from 'pinia'
export const pinia = createPinia() // export pinia
router.beforeEach((to) => {
const store = useUserStore(pinia) // pass pinia explicitly
})
// ✅ Solution 2: get store in setup, use via closure in guards
// Register guard inside a composable that has access to setup context
Pitfall 2: Direct Destructuring Loses Reactivity
// ❌ Wrong: destructured values are plain — not reactive
const { name, isAdmin } = useUserStore()
// ✅ Correct: use storeToRefs
const { name, isAdmin } = storeToRefs(useUserStore())
// ✅ Also correct: keep the full store reference
const store = useUserStore()
// Access as store.name, store.isAdmin (reactive)
Note: actions can be destructured directly — they're plain functions:
const store = useUserStore()
const { name } = storeToRefs(store) // ref, reactive
const { login, logout } = store // plain functions, direct destructure OK
Pitfall 3: $reset() Only Works in Options Store
// ❌ Setup Store: $reset() throws or is unavailable
export const useMyStore = defineStore('my', () => {
const count = ref(0)
return { count }
})
const store = useMyStore()
store.$reset() // TypeError: store.$reset is not a function
// ✅ Implement reset manually in Setup Store
export const useMyStore = defineStore('my', () => {
const count = ref(0)
function reset() {
count.value = 0
}
return { count, reset }
})
Pitfall 4: Arrow Functions Break this in Options Store Actions
export const useUserStore = defineStore('user', {
state: () => ({ name: '' }),
actions: {
async fetchAndUpdate() {
const data = await api.getUser()
this.name = data.name // `this` is store proxy — correct
},
// ❌ Arrow function: `this` is undefined (strict) or outer scope
arrowAction: async () => {
this.name = 'test' // TypeError!
}
}
})
Options Store actions must use regular functions (not arrow functions) to ensure this is correctly bound to the store instance.
Pitfall 5: Cross-Store References and Circular Dependencies
// Store A references Store B
export const useCartStore = defineStore('cart', {
getters: {
// ✅ OK to use another store inside getter function body
total: (state) => {
const priceStore = usePriceStore() // called lazily inside function
return state.items.reduce((sum, item) => {
return sum + item.quantity * priceStore.getPrice(item.id)
}, 0)
}
}
})
// ❌ Circular dependency: A references B, B references A
// Won't crash at runtime (lazy call), but logic becomes unmaintainable
Pitfall 6: Bypassing Store Encapsulation via pinia.state
// ❌ Direct mutation of pinia.state bypasses all tracking
pinia.state.value.user.name = 'hacked'
// Value is changed, but $subscribe callbacks and devtools won't know
// ✅ Always mutate through the store object
const store = useUserStore()
store.name = 'Alice' // through proxy, tracked
store.$patch({ name: 'Alice' }) // batch update, tracked
Chapter Summary
-
Pinia's mutation-free philosophy: Vue 3's reactivity system tracks all state changes at the foundation. Pinia provides
$patchand$subscribeas interception points for devtools — no mutation ceremony required. Direct assignment (store.name = 'Alice') and$patchare functionally equivalent; the difference is only devtools observability. -
Singleton mechanism via Map:
pinia._s(Map<id, store>) ensures each store id is only created once. EveryuseStore()call checks the Map first — cache hit returns immediately, cache miss creates and caches a new instance. -
effectScope is the store's lifecycle container: Every store's reactive side effects (computed, watch) are registered in a detached effectScope. Store lifecycle is independent of using components — component unmount doesn't affect the store; only
store.$dispose()destroys it. -
storeToRefs converts state/getters only, skips actions:
storeToRefsiterates the store, applyingtoRefto create proxy references for ref and reactive values, skipping plain functions (actions). This preserves reactivity for destructured state/getters while allowing actions to be destructured normally. -
Options Store is internally converted to Setup Store: Pinia's core is
createSetupStore; Options Store is a thin syntax layer on top, convertingstate/getters/actionsinto equivalent setup function form. Understanding Setup Store means understanding all of Pinia's runtime mechanics.