Chapter 23

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

  1. Pinia's mutation-free philosophy: Vue 3's reactivity system tracks all state changes at the foundation. Pinia provides $patch and $subscribe as interception points for devtools โ€” no mutation ceremony required. Direct assignment (store.name = 'Alice') and $patch are functionally equivalent; the difference is only devtools observability.

  2. Singleton mechanism via Map: pinia._s (Map<id, store>) ensures each store id is only created once. Every useStore() call checks the Map first โ€” cache hit returns immediately, cache miss creates and caches a new instance.

  3. 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.

  4. storeToRefs converts state/getters only, skips actions: storeToRefs iterates the store, applying toRef to 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.

  5. Options Store is internally converted to Setup Store: Pinia's core is createSetupStore; Options Store is a thin syntax layer on top, converting state/getters/actions into equivalent setup function form. Understanding Setup Store means understanding all of Pinia's runtime mechanics.

Rate this chapter
4.8  / 5  (6 ratings)

๐Ÿ’ฌ Comments