Vue.js 51 🟢 Modern State Management with Pinia (defineStore, Setup Stores vs Option Stores)
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
| Form | Second Argument | State | Getters | Actions |
|---|---|---|---|---|
| Option | Object | state: () => ({...}) | getters: {...} | actions: {...} |
| Setup | Function | ref() | computed() | function() |
Mapping Rules
| Setup Store Construct | Becomes |
|---|---|
const x = ref(0) | State property x |
const y = computed(...) | Getter y |
function z() {} | Action z |
Key Differences
| Feature | Option Store | Setup Store |
|---|---|---|
| Composable support | Limited (writable refs only) | Full |
| Internal watchers | No | Yes |
Built-in $reset() | Yes | No (manual) |
| Must return all state | No | Yes |
| SSR hydration helper | hydrate() | skipHydrate() |
Store Usage
| Operation | Code |
|---|---|
| Get store | const store = useStore() |
| Access state | store.count |
| Call action | store.increment() |
| Destructure state | storeToRefs(store) |
| Destructure actions | const { 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Setup store state missing in devtools | Not all refs returned | Return every state property |
$reset() undefined | Setup Stores lack built-in reset | Implement manually |
| Reactivity lost after destructure | Destructured plain value | Use storeToRefs() |
| SSR breaks with composables | Option Store missing hydrate() | Define hydrate() or use Setup |
| Watcher not working | Option Store has no setup | Use 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
| Item | Value |
|---|---|
| Store definition | defineStore(id, optionsOrSetup) |
| Unique ID | First argument, required |
| Option Store | Object with state, getters, actions |
| Setup Store | Function returning refs, computeds, functions |
| State mapping (setup) | ref() → state |
| Getter mapping (setup) | computed() → getter |
| Action mapping (setup) | function() → action |
| Built-in reset | Option Stores only |
| Reactivity destructure | storeToRefs() |
| Setup Store requirement | Return all state properties |
Key takeaways:
defineStore()has two forms. Option Stores use astate/getters/actionsobject. Setup Stores use a function returningrefs,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(), andwatch()inside a Setup Store, which is impossible in an Option Store . - Option Stores have a built-in
$reset(). Setup Stores require a manual$resetimplementation . - 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 useskipHydrate()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.
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!