| |

Vue.js 51 🟢 Modern State Management with Pinia (defineStore, Setup Stores vs Option Stores)

⏱️ Estimated read: 12 min | |

Pinia is the official state management library for Vue, and it has largely replaced Vuex as the default choice for new Vue 3 applications. Its core API is built around a single function: defineStore(). This function creates a store definition—a reusable, type-safe container for state, computed values, and methods that can be shared across components . What makes Pinia interesting is not just that it works, but that it offers two distinct ways to define a store, each with its own philosophy and trade-offs.

The two syntaxes are Option Stores and Setup Stores. Option Stores resemble Vue’s Options API: you pass an object with state, getters, and actions properties, and Pinia maps these to the store’s reactive data, computed values, and methods . Setup Stores resemble Vue’s Composition API: you pass a function that declares refs, computeds, and plain functions, and returns the ones you want to expose . Both produce the same kind of store object. The difference is in how you write them and what you can do inside them.

This chapter covers defineStore() in both forms, the mapping rules that Pinia applies to each, the practical differences that matter in real applications, and the pitfalls specific to each syntax.

Key point: defineStore() accepts either an Options object (state, getters, actions) or a Setup function (refs, computeds, functions); both produce a store with the same runtime behavior, but Setup Stores offer more flexibility for composables and watchers at the cost of requiring you to return every state property explicitly.


Why two store syntaxes exist

The Vuex migration path. Developers coming from Vuex are accustomed to an object-based store definition: state as an object, mutations and actions as methods. Pinia’s Option Stores preserve that mental model while removing mutations entirely—actions can mutate state directly . This makes migration from Vuex relatively mechanical, and it gives teams a familiar starting point without forcing them into the Composition API immediately.

The Composition API alignment. Vue 3 introduced the Composition API as a more flexible alternative to the Options API. Setup Stores mirror this: a store is just a setup() function that returns reactive state and functions, and Pinia infers which is which . For developers already writing <script setup> components, this feels natural. It also enables patterns that Option Stores cannot express, such as using composables that expose functions or readonly data, or creating watchers inside the store .

The flexibility trade-off. Option Stores are constrained by their shape: state must be a function returning a plain object, and only writable refs can be placed there . Setup Stores have no such constraint. Any ref, computed, or function you return becomes part of the store, and you can use almost any composable inside the setup function . The cost is that you must return every state property—Pinia cannot see what you do not expose, and missing properties break SSR, devtools, and plugins .

The reset behavior difference. Option Stores have a built-in $reset() method that restores the store to its initial state. Setup Stores do not—if you need reset functionality, you must implement it manually or use a plugin . This is a small but practical difference that catches developers who switch between syntaxes.

The SSR hydration difference. When using composables that hold client-only state (like useLocalStorage), Option Stores require a hydrate() function to handle server-side rendering correctly. Setup Stores use skipHydrate() on the specific properties that should not be picked up from the initial state . Both solve the same problem, but the mechanisms differ.


a. Option Stores: the familiar shape

An Option Store is defined by passing an object as the second argument to defineStore(). The object has three optional properties: state, getters, and actions.

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++
    }
  }
})

The state property is a function that returns the initial state object. It must be a function, not a plain object, so that each store instance gets its own fresh copy . The getters property contains computed values. Getters receive state as their first argument, and they can access other getters through this . The actions property contains methods. Actions can mutate state directly via this, and they can be asynchronous.

The mapping is straightforward: state is the data, getters are computed properties, actions are methods . If you have used Vue’s Options API, this feels immediately familiar. The store object you get from useCounterStore() exposes count and name as reactive properties, doubleCount as a computed property, and increment as a method.

Option Stores have a built-in $reset() method. Calling store.$reset() restores the state to the values returned by the state function. This works because Pinia knows the initial state and can re-run the function .

The limitation of Option Stores is the shape of state. It must be a plain object containing writable reactive values. If you try to use a composable that returns readonly data or functions (like useEyeDropper), you cannot place it in state . You also cannot create watchers inside an Option Store—there is no setup function where they would live.


b. Setup Stores: the Composition API shape

A Setup Store is defined by passing a function as the second argument to defineStore(). The function declares reactive state with ref(), computed values with computed(), and methods as plain functions, then returns an object containing the ones to expose.

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return { count, name, doubleCount, increment }
})

The mapping rules are different from Option Stores. ref() becomes state. computed() becomes a getter. A plain function becomes an action . Pinia inspects the returned object and categorizes each property by its type.

The critical requirement is that you must return every state property you want Pinia to track. There are no private state properties in a Setup Store—if you create a ref and do not return it, Pinia does not know it exists, and SSR hydration, devtools, and plugins will break . This is the most common mistake when writing Setup Stores.

The flexibility is significant. You can use any composable inside a Setup Store, including ones that expose functions or readonly data . You can create watch and watchEffect inside the store, because you are inside a setup function. You can use inject() to access app-level provides, such as the router . None of this is possible in an Option Store.

The trade-off is that Setup Stores do not have a built-in $reset(). You must implement reset manually, typically by resetting each ref to its initial value .

function $reset() {
  count.value = 0
  name.value = 'Eduardo'
}

c. Choosing between them and using stores correctly

The choice between Option and Setup Stores is largely a matter of team preference and store complexity. Option Stores are simpler for straightforward state containers—CRUD operations, user sessions, simple counters. Setup Stores are necessary when you need composables, watchers, or maximum flexibility .

The official guidance is pragmatic: pick the one you feel most comfortable with . Both have strengths. Option Stores are easier to read for developers from a Vuex background. Setup Stores are more powerful and align with modern Vue 3 patterns.

Regardless of syntax, the usage pattern is the same. You call the store function inside a component’s setup() or <script setup>:

const counter = useCounterStore()
counter.count++
counter.increment()

When you need to destructure state or getters while preserving reactivity, use storeToRefs():

import { storeToRefs } from 'pinia'
const { count, doubleCount } = storeToRefs(counter)

Actions can be destructured directly because they are bound to the store . State and getters cannot—destructuring them without storeToRefs() breaks reactivity.


Complete Example Session

The following session defines the same store in both syntaxes and demonstrates the differences in practice.

// ============================================
// PART 1: OPTION STORE DEFINITION
// ============================================
import { defineStore } from 'pinia'

export const useTaskStoreOptions = defineStore('tasks-options', {
  state: () => ({
    tasks: [],
    filter: 'all'
  }),
  getters: {
    completed: (state) => state.tasks.filter(t => t.done),
    pending: (state) => state.tasks.filter(t => !t.done),
    filtered: (state) => {
      if (state.filter === 'completed') return state.tasks.filter(t => t.done)
      if (state.filter === 'pending') return state.tasks.filter(t => !t.done)
      return state.tasks
    }
  },
  actions: {
    add(title) {
      this.tasks.push({ id: Date.now(), title, done: false })
    },
    toggle(id) {
      const task = this.tasks.find(t => t.id === id)
      if (task) task.done = !task.done
    },
    $reset() {
      this.tasks = []
      this.filter = 'all'
    }
  }
})

// ============================================
// PART 2: SETUP STORE DEFINITION
// ============================================
import { ref, computed, watch } from 'vue'

export const useTaskStoreSetup = defineStore('tasks-setup', () => {
  const tasks = ref([])
  const filter = ref('all')

  const completed = computed(() => tasks.value.filter(t => t.done))
  const pending = computed(() => tasks.value.filter(t => !t.done))
  const filtered = computed(() => {
    if (filter.value === 'completed') return tasks.value.filter(t => t.done)
    if (filter.value === 'pending') return tasks.value.filter(t => !t.done)
    return tasks.value
  })

  function add(title) {
    tasks.value.push({ id: Date.now(), title, done: false })
  }

  function toggle(id) {
    const task = tasks.value.find(t => t.id === id)
    if (task) task.done = !task.done
  }

  // Watcher inside store — only possible in Setup Stores
  watch(tasks, (newTasks) => {
    console.log(`${newTasks.length} tasks total`)
  }, { deep: true })

  function $reset() {
    tasks.value = []
    filter.value = 'all'
  }

  return { tasks, filter, completed, pending, filtered, add, toggle, $reset }
})

// ============================================
// PART 3: USING THE OPTION STORE
// ============================================
const optStore = useTaskStoreOptions()
optStore.add('Buy milk')
optStore.toggle(optStore.tasks[0].id)
console.log(optStore.completed.length) // 1
optStore.$reset() // built-in, resets to initial state

// ============================================
// PART 4: USING THE SETUP STORE
// ============================================
const setupStore = useTaskStoreSetup()
setupStore.add('Buy milk')
setupStore.toggle(setupStore.tasks[0].id)
console.log(setupStore.completed.length) // 1
setupStore.$reset() // manual reset

// ============================================
// PART 5: DESTRUCTURING WITH storeToRefs
// ============================================
import { storeToRefs } from 'pinia'

const { tasks, completed } = storeToRefs(setupStore)
// tasks and completed remain reactive
// setupStore.add and setupStore.toggle can be destructured directly
const { add, toggle } = setupStore

The ten parts cover the Option Store definition, the Setup Store definition with a watcher, using the Option Store with its built-in reset, using the Setup Store with a manual reset, and destructuring with storeToRefs. What the session demonstrates is that both syntaxes produce equivalent stores, but Setup Stores allow internal watchers and require explicit reset implementation.


Quick Reference

defineStore() Signatures

FormSecond ArgumentStateGettersActions
OptionObjectstate: () => ({...})getters: {...}actions: {...}
SetupFunctionref()computed()function()

Mapping Rules

Setup Store ConstructBecomes
const x = ref(0)State property x
const y = computed(...)Getter y
function z() {}Action z

Key Differences

FeatureOption StoreSetup Store
Composable supportLimited (writable refs only)Full
Internal watchersNoYes
Built-in $reset()YesNo (manual)
Must return all stateNoYes
SSR hydration helperhydrate()skipHydrate()

Store Usage

OperationCode
Get storeconst store = useStore()
Access statestore.count
Call actionstore.increment()
Destructure statestoreToRefs(store)
Destructure actionsconst { action } = store

Best Practices

✅ Do This:

const count = ref(0); return { count }                     // ✅ return all state
const { count } = storeToRefs(store)                       // ✅ preserve reactivity
function $reset() { count.value = 0 }                      // ✅ manual reset in setup
watch(tasks, () => {})                                     // ✅ watchers in setup stores

❌ Don’t Do This:

const private = ref(0); // not returned                    // ❌ breaks SSR/devtools
const { count } = store                                    // ❌ loses reactivity
store.$reset() // in setup store without implementation    // ❌ undefined
// use composable returning readonly in option state       // ❌ not allowed

Common Pitfalls

PitfallWhy It HappensFix
Setup store state missing in devtoolsNot all refs returnedReturn every state property
$reset() undefinedSetup Stores lack built-in resetImplement manually
Reactivity lost after destructureDestructured plain valueUse storeToRefs()
SSR breaks with composablesOption Store missing hydrate()Define hydrate() or use Setup
Watcher not workingOption Store has no setupUse Setup Store

Real-World Examples

1. Option Store for user session

export const useUserStore = defineStore('user', {
  state: () => ({ user: null, token: null }),
  actions: { login(u, t) { this.user = u; this.token = t } }
})

2. Setup Store with composable

export const useAuthStore = defineStore('auth', () => {
  const user = useLocalStorage('user', null)
  return { user }
})

3. Setup Store with watcher

watch(count, (v) => console.log('count changed', v))

4. Store composition in setup

const user = useUserStore()
const cart = ref([])

5. StoreToRefs for state

const { count, doubleCount } = storeToRefs(counterStore)

6. Action destructuring

const { increment } = counterStore

7. Option Store reset

store.$reset()

8. Setup Store manual reset

function $reset() { count.value = 0 }

9. SkipHydrate for client-only ref

return { color: skipHydrate(lastColor) }

10. Hydrate in Option Store

hydrate(state) { state.user = useLocalStorage('user', null) }

Visual

Option Store Structure

┌──────────────────────────────────────────────┐
│  defineStore('id', {                         │
│    state: () => ({ count: 0 }),              │
│    getters: { double: (s) => s.count * 2 },  │
│    actions: { increment() { this.count++ } } │
│  })                                          │
│                                              │
│  state    → data                             │
│  getters  → computed                         │
│  actions  → methods                          │
│  $reset() → built-in                         │
└──────────────────────────────────────────────┘

Setup Store Structure

┌──────────────────────────────────────────────┐
│  defineStore('id', () => {                   │
│    const count = ref(0)                      │
│    const double = computed(() => count.value * 2) │
│    function increment() { count.value++ }    │
│    return { count, double, increment }       │
│  })                                          │
│                                              │
│  ref()       → state                         │
│  computed()  → getter                        │
│  function()  → action                        │
│  $reset()    → manual (if needed)            │
└──────────────────────────────────────────────┘

Decision Flow

┌──────────────────────────────────────────────┐
│  Need composables?                           │
│  ├─ YES → Setup Store                        │
│  └─ NO → Need watchers inside store?         │
│          ├─ YES → Setup Store                │
│          └─ NO → Need built-in $reset?       │
│                  ├─ YES → Option Store       │
│                  └─ NO → Either (preference) │
└──────────────────────────────────────────────┘

Store Usage and Reactivity

┌──────────────────────────────────────────────┐
│  const store = useStore()                    │
│                                              │
│  store.count          ← reactive             │
│  store.double         ← reactive             │
│  store.increment()    ← action               │
│                                              │
│  const { count } = storeToRefs(store)        │
│  ← count remains reactive                    │
│                                              │
│  const { increment } = store                 │
│  ← actions can be destructured directly      │
└──────────────────────────────────────────────┘

Summary

ItemValue
Store definitiondefineStore(id, optionsOrSetup)
Unique IDFirst argument, required
Option StoreObject with state, getters, actions
Setup StoreFunction returning refs, computeds, functions
State mapping (setup)ref() → state
Getter mapping (setup)computed() → getter
Action mapping (setup)function() → action
Built-in resetOption Stores only
Reactivity destructurestoreToRefs()
Setup Store requirementReturn all state properties

Key takeaways:

  • defineStore() has two forms. Option Stores use a state/getters/actions object. Setup Stores use a function returning refs, computeds, and functions .
  • Both forms produce equivalent stores. The runtime behavior is the same; the difference is in authoring flexibility and constraints.
  • Setup Stores require returning every state property. Pinia cannot track what you do not expose, and missing properties break SSR and devtools .
  • Setup Stores enable composables and watchers. You can use useRoute(), useLocalStorage(), and watch() inside a Setup Store, which is impossible in an Option Store .
  • Option Stores have a built-in $reset(). Setup Stores require a manual $reset implementation .
  • Use storeToRefs() for state and getters. Destructuring them without it breaks reactivity. Actions can be destructured directly.
  • SSR hydration differs. Option Stores use hydrate(); Setup Stores use skipHydrate() on client-only properties .

Remember: Pinia’s defineStore() gives you two ways to build the same thing. Option Stores are familiar, simple, and include a built-in reset. Setup Stores are flexible, composable-aware, and align with the Composition API, but require you to return every state property and implement reset yourself. Choose Option Stores for straightforward state containers and teams coming from Vuex. Choose Setup Stores when you need composables, watchers, or maximum control. Both produce stores that work identically in components—the choice is about how you want to write the definition, not how the store behaves at runtime.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!