Pinia's defineStore takes a store id and then either an options object, which makes an option store, or a function, which makes a setup store. Both give you a store with state, getters and actions, and the components that use it cannot tell them apart. The choice only changes how you write the inside of the store.
Use setup stores for new code, return every piece of state from them, and write their $reset method yourself. Whichever style you pick, use it across the whole codebase: a project with both styles is harder to read than a project with either.
The cart as an option store
The example is a small shopping cart, written once in each style: a list of items and a coupon code, two derived values (count and total), and actions to add and remove items.
import { defineStore } from 'pinia'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
coupon: '',
}),
getters: {
count: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
total(state): number {
const sum = state.items.reduce((s, item) => s + item.price * item.quantity, 0)
return this.coupon === 'VUE10' ? sum * 0.9 : sum
},
},
actions: {
add(item: Omit<CartItem, 'quantity'>) {
const existing = this.items.find(i => i.id === item.id)
if (existing) existing.quantity++
else this.items.push({ ...item, quantity: 1 })
},
remove(id: number) {
this.items = this.items.filter(i => i.id !== id)
},
},
})
This reads like the Options API, and if you have worked with Vuex, every word here is familiar:
stateis a function that returns the initial object, the same waydata()does in a component.gettersare computed values. Each one receives the state as its argument.actionsare methods, andthisinside them is the store.
A getter written as a regular function can also read the rest of the store through this, as total does to read coupon. When it does, TypeScript needs the return type written out, as in total(state): number. Leave it off and the store's type loses its getters: store.count and store.total become type errors in the components that use them.
The same cart as a setup store
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface CartItem {
id: number
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const coupon = ref('')
const count = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0))
const total = computed(() => {
const sum = items.value.reduce((s, item) => s + item.price * item.quantity, 0)
return coupon.value === 'VUE10' ? sum * 0.9 : sum
})
function add(item: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(i => i.id === item.id)
if (existing) existing.quantity++
else items.value.push({ ...item, quantity: 1 })
}
function remove(id: number) {
items.value = items.value.filter(i => i.id !== id)
}
return { items, coupon, count, total, add, remove }
})
This reads like <script setup>. The second argument is a function. Inside it you write ordinary Composition API code, with .value on every ref, and at the end you return the things the store should expose. There is no this, no annotation on total, and count and total are typed by inference, the same way they would be in a component.
How the pieces map
The two files describe one store, and the correspondence is exact:
- A
ref()you return becomes a state property.itemsandcouponappear instore.$state(the object that holds all of the store's state), in devtools and in anything a plugin persists. - A
computed()you return becomes a getter.store.countreads as a number, with no.value, because the store unwraps refs. - A function you return becomes an action. It is bound to the store, so
const { add } = useCartStore()works and so does passingstore.addas a callback.
Code that uses the store is identical for both styles:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
const { count, total } = storeToRefs(cart)
</script>
<template>
<button type="button" @click="cart.add({ id: 1, name: 'Mug', price: 12 })">
{{ count }} items, {{ total.toFixed(2) }}
</button>
</template>
storeToRefs pulls reactive refs out of the store so that destructuring does not lose reactivity, and it skips actions, which you call on the store itself. Whether cart came from an options object or a setup function makes no difference to this file.
What setup stores can do that option stores cannot
The Pinia documentation says you can pick whichever style you are more comfortable with, calls option stores easier to work with, and calls setup stores more flexible and powerful. The flexibility comes down to three things.
First, you can call composables inside a setup store. A store that needs the current route calls useRoute(); a store that needs something provided at the app level calls inject(). The docs ask you not to return those from the store, because components can reach them directly, but you can use them to compute state.
An option store can call a composable only inside state(), and only one that returns writable state, such as VueUse's useLocalStorage. The docs' page on composables in stores lists ones that do not fit, such as composables that return functions or read-only values.
Second, you can create watchers. A cart that saves itself is three lines inside the setup function in stores/cart.ts (add watch to the import from vue):
watch(items, (value) => {
localStorage.setItem('cart', JSON.stringify(value))
}, { deep: true })
The watcher lives as long as the store, which is as long as the app. In an app that also renders on the server, guard browser APIs like localStorage with a check for window, or run the watcher from a client-only plugin.
Third, you can have private helpers that work on the store's refs directly. A function you do not return is invisible to components and to devtools, which is a clean way to keep a parsing routine or a validation step out of the public surface. This works for functions only. State cannot be private, as the next section shows.
The one rule: return everything that is state
The setup syntax has one rule that the options syntax enforces for you. Every ref that holds state must be in the returned object. This version runs, and is wrong:
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const coupon = ref('')
const total = computed(() => {
const sum = items.value.reduce((s, item) => s + item.price * item.quantity, 0)
return coupon.value === 'VUE10' ? sum * 0.9 : sum
})
function applyCoupon(code: string) {
coupon.value = code
}
return { items, total, applyCoupon }
})
coupon is used, changed by an action and read by a getter, but never returned. Pinia does not know it exists, so it is missing from store.$state. That means:
- devtools cannot show or edit it;
$patchcannot set it;- a persistence plugin will not save it;
- with server-side rendering its value is lost, because only
$stateis serialised and sent to the browser.
The docs say the same about wrapping state in readonly() before returning it: it breaks SSR, devtools and plugins in the same way.
The fix is one word:
return { items, coupon, total, applyCoupon }
If you want a piece of state to look private, name it with a leading underscore and leave it out of the components by convention. Do not leave it out of the return.
Write your own $reset in a setup store
Every option store has a $reset() method. It calls your state() function again and replaces the current state with the fresh object, which is exactly what a logout button needs.
A setup store has no state() function to call again, so Pinia cannot write $reset for you. Calling it on a setup store throws in development, with the message 🍍: Store "cart" is built using the setup syntax and does not implement $reset(). In a production build the call does nothing at all, so a logout that relies on it would leave the old cart in place. Write the method yourself in stores/cart.ts and return it under the same name:
function $reset() {
items.value = []
coupon.value = ''
}
return { items, coupon, count, total, add, remove, $reset }
Keeping the name $reset means the code that resets stores on logout does not need to know which style each store uses. The price of writing it by hand: when you add a new ref to the store, you have to remember to add it to $reset too.
Pick setup stores for new code
For a new store, use the setup syntax. It is the same Composition API you already write in components, it gives you composables and watchers when you need them, its getters need no type annotations, and the one rule it adds is easy to follow once you know it. The cost is $reset, a four-line function.
If your project already has option stores, do not rewrite them for the sake of it. They work, Pinia 3 supports them fully, and a rewrite without a reason is a way to introduce bugs. Choose one style for new stores, write it down in the project's contributing notes, and convert an old store only when you are changing it anyway. The mapping above makes that conversion mechanical: state to refs, getters to computeds, actions to functions, a return object at the end that lists every ref, and a $reset if anything calls it.