Setup stores
Write a Pinia store the way you write a component, with refs, computed values and functions, and add the $reset() that setup stores lack.
What you'll learn
When you finish, you can do these.- 1Write a setup store with refs, computed() and functions
- 2Return every ref that holds the store's state
- 3Use watch() and inject() inside a setup store
- 4Add your own $reset() to a setup store
A shop's cart holds the items a visitor picked and adds up their total. With Pinia, Vue's official store library, the cart lives in a store, one place every component can read and change. Pinia has two styles of store. The first, the option store, is an object with three fixed blocks: state for the data, getters for values worked out from it and actions for functions that change it. It has no place for Vue's own functions, such as watch(), which runs code when a value changes, so a cart that saves itself in the browser does not fit.
A setup store has room for them: you write it like <script setup>, with refs, computed values and plain functions inside one function. The Code tab holds that cart: stores/cart.ts, a store that saves itself, and App.vue, whose buttons add a Sticker for $6 and a Mug for $14. You need one idea from the lesson Your first store: defineStore() returns a function, here useCartStore(), and every component that calls it gets the same store.
The same parts, written as a function
A setup store has the same three parts as an option store, written as one function. defineStore() takes the store's id, a unique name such as 'cart', and that function in place of the object. Pinia calls it once, when a component first uses the store, and turns each value it returns into one of the three parts.
ref()computed()functionThe cart store in the Code tab, stores/cart.ts, is written in the map's order. First the state, as a ref. Item, a type declared at the top of the file, is a name and a price.
export const useCartStore = defineStore('cart', () => {
const items = ref<Item[]>(JSON.parse(localStorage.getItem('setup-cart') ?? '[]'))
items starts with the cart saved under setup-cart in localStorage, the browser's storage for text. JSON.parse() turns that text back into a list; when nothing is saved yet, it reads '[]', an empty list, instead.
Then the getter, as a computed value.
const total = computed(() => items.value.reduce((sum, i) => sum + i.price, 0))
total adds up the prices. Like any computed(), it is cached and recalculates only when items changes.
Then the action, as a plain function, and the return that hands all three parts to Pinia.
function add(name: string, price: number) {
items.value.push({ name, price })
}
return { items, total, add }
})
Inside add() you write items.value, as in any <script setup>, not this.items as in an option store.
Components cannot tell the two styles apart. App.vue calls useCartStore(), keeps the store in cart, reads cart.items and cart.total and calls cart.add(), as it would with an option store. On an empty cart, click Add sticker and Add mug once each: the line reads 2 in the cart · total $20.
So choose by what you need: an option store gives you a fixed shape and a built-in reset, a setup store everything <script setup> offers. When unsure, the Pinia docs suggest option stores first.
Return every piece of state
A setup store has one rule an option store does not need: Pinia only knows what the function returns. A ref you leave out still works for the store's own code, such as add(), but it is not part of the store. Components cannot read it, and Vue DevTools, the browser extension for inspecting Vue apps, does not list it. Server rendering, where a server builds the page first, does not send it along, and plugins, add-ons that Pinia runs for every store, never see it.
So return every ref that holds state; in the cart store, that is items. A value that never changes, such as a tax rate, can stay private.
Things only a setup store can do
Pinia runs the function like the setup of a component, so a setup store can call anything <script setup> can. The cart store uses watch(), which runs code each time a value changes, to save items after every change.
watch(items, (list) => {
localStorage.setItem('setup-cart', JSON.stringify(list))
}, { deep: true })
JSON.stringify() turns the list into text, saved under setup-cart, the name the ref reads. deep: true makes the watcher notice a push() into the array, not only a new array.
Add two stickers in the preview, then type a space at the end of main.ts, the file that starts the app. The preview restarts, and the cart comes back with the same count and total.
A setup store can also call composables, the reusable functions built from refs and watchers that the Advanced track teaches, and inject(), which reads a value that main.ts hands to the whole app with app.provide(). An option store has nowhere to make these calls.
Vue Router, the official library that shows a page for each address, builds its useRoute() helper on inject(). So a setup store that needs the current page calls useRoute() at the top of its function.
What goes wrong
The first mistake is a ref left out of the return. Here the cart store's return forgets items.
return { total, add }
add() and total still work, because both use the ref inside the function. But cart.items is undefined, so {{ cart.items.length }} in App's template throws Cannot read properties of undefined (reading 'length') and the page goes blank. Return it.
return { items, total, add }
The cart's line comes back.
The second mistake is calling $reset() on a setup store. On an option store, this built-in method puts every value back to its start, because Pinia can call state(), the function that returns the starting data, again. A setup store has no state() function, so Pinia cannot know what the starting values were. Here an Empty button in App.vue tries it.
<button @click="cart.$reset()">Empty</button>
In development, and in the playground, the click throws 🍍: Store "cart" is built using the setup syntax and does not implement $reset(). A production build, the version you publish, skips that check, so there the click silently does nothing. Write your own $reset in the store and return it under that name.
function $reset() {
items.value = []
}
return { items, total, add, $reset }
Components call it the same way, so the Empty button now clears the cart and the line reads 0 in the cart · total $0.
What to remember
- A setup store is a function: refs become state, computed values become getters, and functions become actions.
- Components use both kinds of store the same way, so choose the style by what you need.
- Return every ref that holds state. Components, DevTools, server rendering and plugins only see what you return.
- Inside a setup store you can use
watch(), composables andinject(), which an option store has no place for. - Only option stores have a built-in
$reset(). A setup store writes its own and returns it.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Write a reset. Add
<button @click="cart.$reset()">Empty</button>to App and click it: the cart does not change and the preview shows🍍: Store "cart" is built using the setup syntax and does not implement $reset().Now addfunction $reset() { items.value = [] }to the store and return it. Empty clears the cart and the line says 0 in the cart · total $0. - Read a value the app provides. In
main.ts, addapp.provide('currency', '€')beforeapp.mount(). In the store, importinjectfromvue, addconst currency = inject('currency', '$')and returncurrency. In App, replace${{ cart.total }}with{{ cart.currency }}{{ cart.total }}. The total now shows €. - Forget to return state. Remove
itemsfrom the store's return. The preview goes blank and showsCannot read properties of undefined (reading 'length'). Putitemsback and the cart reappears.