Skip to main content

Pinia — defining and splitting stores

The provide / inject in chapter 12 distributed values down the component tree. It does not suit sharing that ignores the shape of the tree — a header and a cart screen looking at the same basket, or login state read from any screen.

Pinia puts state outside the tree. A store created with defineStore() exists once per app, and every component gets the same instance. This chapter covers Pinia as the state management library Vue officially recommends.

What you'll learn in this chapter​

  • The two ways to define a store (option and setup), and how the official docs frame the choice
  • When storeToRefs is needed, and why actions alone do not need it
  • The two forms of $patch, and how assignment to $state is handled
  • When $subscribe is called: $patch and direct assignment differ in timing
  • Why $reset is unavailable in setup stores, and how the behaviour differs between development and production builds

Setup​

After npm install pinia, plug it into the app.

src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

createApp(App).use(createPinia()).mount('#app')

createPinia() creates the container for state and app.use() plugs it into the app. The store definitions know nothing about createPinia().

Two ways to write a store​

Option stores​

Pass an object with state / getters / actions. It is close to the Options API.

src/stores/cart.ts
import { defineStore } from 'pinia'

interface Item {
id: number
name: string
quantity: number
}

export const useCartStore = defineStore('cart', {
state: () => ({ items: [] as Item[] }),
getters: {
total: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
},
actions: {
add(item: Item) {
// gather array operations in the function form of $patch
this.$patch((state) => {
state.items.push(item)
})
},
async load() {
this.items = await (await fetch('/api/cart')).json()
},
},
})

Inside an action, this is the store itself. There is no equivalent of Vuex's mutations; actions write to state directly. Add async and it becomes an async action as-is.

Setup stores​

Pass a function and its body is written the same way as <script setup>. ref() is state, computed() is getters, and functions are actions.

src/stores/counter.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Tanaka')

const doubled = computed(() => count.value * 2)

function increment() {
count.value += 1
}

// return every piece of state; what you do not return never appears in $state
return { count, name, doubled, increment }
})

Which to choose​

The official docs treat it the same as the choice between the Options API and the Composition API, and recommend neither. They say "Options stores are easier to work with while Setup stores are more flexible and powerful" and tell you to pick the one you are used to. The reason they call setup stores "more flexible and powerful" is that anything you can write in <script setup> can be written there: setting up a watch() inside the definition, holding a local variable that never reaches $state, returning a writable computed — none of which an option store can do. Conversely, merely calling another store works from an option store's actions too. But composables using inject(), such as useRouter(), do not work from an action (they return undefined, with a warning in development builds). They do work in a setup store's body.

Setup stores come with one caveat in exchange. Every piece of state has to be returned. The docs write "Not returning all state properties or making them readonly will break SSR, devtools, and other plugins". A ref you forgot to return is still alive inside the store. While that store keeps running from the start, computeds and actions referring to it return correct values. What disappears is that ref itself.

const useBad = defineStore('bad', () => {
const shown = ref(0)
const hidden = ref(0)
const sum = computed(() => shown.value + hidden.value)
function bump() {
shown.value += 1
hidden.value += 1
}
return { shown, sum, bump } // hidden is not returned
})

const store = useBad()
store.bump()

store.sum // 2 — hidden became 1 as well
store.hidden // undefined — unreadable through the store
Object.keys(store.$state) // ['shown'] — hidden is not in there

What breaks is injecting state from outside. SSR hydration, devtools, and plugins all handle state through $state. Call bump() on the server and hand state to the client, and shown is restored to 1 while hidden starts from 0. The browser's sum becomes 1 rather than the server's 2, and no error is raised. In an SSR setup such as Nuxt, this surfaces as "the number differs between server and client".

Using it from a component​

Just call use...Store(). What comes back is a reactive object, so the template reads store.count directly.

The problem is destructuring. const { count } = store extracts the value and does not follow later changes. Put it through storeToRefs() and you get refs that do follow.

src/components/CartSummary.vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '../stores/cart'

const store = useCartStore()

// turn state and getters into refs with storeToRefs
const { items, total } = storeToRefs(store)
// actions can be destructured directly
const { add } = store
</script>

<template>
<p>{{ total }} items</p>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
<button type="button" @click="add({ id: 1, name: 'Shoes', quantity: 1 })">Add</button>
</template>

Leaving actions out of storeToRefs is not an oversight. storeToRefs picks up only refs and reactive things. The docs explain "It will create refs for every reactive property" and annotate the sample code with:

but skip any action or non reactive (non ref/reactive) property

Actions are not the only thing left out. Plain values a setup store returned (a constant such as const label = 'label' that is not a ref) are excluded too. An action is a function bound to the store, so extracting it does not lose this. There is no point making it a ref, hence the exclusion.

State held in a ref is writable. Write <input v-model="count"> and input goes straight into the store's state. Getters also come back as refs with setters, but whether writes land depends on the original definition. An option store's getters become read-only computeds, so assignment has no effect. If a setup store returned computed({ get, set }), it lands.

There is one more shape that does not land. When a setup store returns a reactive() object as state, assigning a different object to that ref changes only the store side and never reaches $state. Overwriting the inner values one by one does reach it.

Changing several at once — $patch​

To treat several values as one change, use $patch(). There are two forms.

// object form — applies a diff
store.$patch({ count: 10, name: 'Sato' })

// function form — gathers changes awkward to express as a patch object
store.$patch((state) => {
state.items.push(item)
state.hasChanged = true
})

The function form is for changes hard to express as a diff object, such as push and splice on arrays. In either form, the changes made inside are gathered into a single devtools entry.

Assignment to $state is also a $patch(). The merge depth differs, though. The object form of $patch() merges nested objects recursively, while the $state setter is $patch($state => Object.assign($state, value)) and only looks one level down.

// with state as { user: { name: 'Tanaka', age: 30 }, tag: 'a' }
store.$patch({ user: { name: 'Sato' } })
store.user // { name: 'Sato', age: 30 } — age survives

store.$state = { user: { name: 'Sato' } }
store.user // { name: 'Sato' } — age is gone
store.tag // 'a' — the first level survives

$patch() recurses only when both sides are plain objects. Anything not a plain object — an array, a Date, something wrapped in reactive() — is replaced wholesale even when nested.

To update nested state partially, use $patch(). The $state reference itself never changes, so it is not a way to swap out the state container either.

Receiving changes — $subscribe​

Register a callback called when state changes. Persisting to localStorage is the representative use.

src/components/CartPersist.vue
<script setup lang="ts">
import { useCartStore } from '../stores/cart'

const store = useCartStore()

// by default this is removed when the component unmounts
store.$subscribe((mutation, state) => {
localStorage.setItem('cart', JSON.stringify(state))
if (mutation.type === 'patch object') {
console.log(mutation.payload)
}
})
</script>

<template>
<p>Persisting {{ store.total }} items</p>
</template>

mutation.type describes the entry point of the change. There are three values.

typeWhat you didpayload
'direct'Assigned directly, as in store.count = 1None
'patch object'Called $patch({ ... })The object passed to $patch()
'patch function'$patch(state => ...), assignment to $state, or an option store's $reset()None

payload is present only for 'patch object'. The 'patch function' and 'direct' mutations do not carry a payload key at all.

There are three routes to 'patch function' because the latter two call the function form of $patch() internally. From $subscribe's point of view, $reset() is indistinguishable from a function-form patch.

There are two notification timings​

$patch notifies on the spot. Direct assignment waits until the next tick. Internally it is Vue's watch(), whose default flush is 'pre'. $patch alone calls the callback directly at the end of the patch.

store.$subscribe((m) => log.push(m.type))

store.$patch({ count: 1 })
// log is ['patch object'] — already in

store.count = 2
// log has not grown yet
await nextTick()
// 'direct' goes in here

$subscribe's second parameter passes watch()'s options straight through. Pass { flush: 'sync' } and direct assignment notifies on the spot too.

That difference produces one behaviour the official guide does not document. With the default flush, a direct assignment in the same tick as a $patch is not notified.

store.$patch({ count: 1 })
store.count = 2
await nextTick()
// the value is 2, but the notifications are only ['patch object']; no 'direct' arrives

$patch suspends notification while it runs and restores it on nextTick(). Notification under the default flush: 'pre' runs on the same tick's flush, so it passes by before the restore. A subscription given { flush: 'sync' } does not miss it — the synchronous flag is restored before the patch's notification, so both arrive.

If you use $subscribe as a log of state changes, either pass { flush: 'sync' } or do not mix direct assignment in immediately after a $patch. Even at the default, crossing a tick notifies both.

When a registered subscription is removed​

By default, $subscribe() binds to the effect scope active at the moment it is called, and is removed when that scope is discarded. A component's setup creates a scope, so in practice it reads as "removed when that component unmounts". To keep it, pass { detached: true }.

What it binds to is not "the component" but the scope at that moment. Call $subscribe() inside a Pinia plugin and it binds to the store's own scope (plugins run inside the scope that creates the store). $dispose() on the store removes it. The only thing that survives without { detached: true } is one called where there is no scope at all, such as at a module's top level.

// keep persisting even after this component goes away
store.$subscribe(save, { detached: true })

Pass the same function twice and the second is discarded. The duplicate is not registered, and the unsubscribe function returned does nothing. The development build emits a three-line diagnostic.

[PINIA_R1007] The same callback was passed to "$subscribe()" of store "cart" more than once. Subscriptions are deduplicated, so the duplicate is ignored.
├▶ fix: Subscribe each callback only once. If you need to resubscribe, call the returned function to remove the previous subscription first, or create a new function.
╰▶ see: https://pinia.vuejs.org/core-concepts/state.html#Subscribing-to-the-state

The discarding itself is the same in production builds; only the diagnostic disappears. To unsubscribe, hold on to the first call's return value.

mutation also carries events for devtools, but it is undefined in production builds. Even in development builds its shape varies by route (an array of events for $patch, a single event for direct assignment). Do not rely on it for logging.

$reset varies with how you wrote the store​

An option store's $reset() calls state() again and applies the result. The function that builds the initial values is right there, so it can be re-run any time. It applies with Object.assign, so keys not in state() survive (the official docs say "replaces the current state", but it is not actually a replacement).

Setup stores have no such function. The docs say "you need to create your own $reset() method". Put a function named $reset in the returned object and it is used as-is.

src/stores/filters.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useFiltersStore = defineStore('filters', () => {
const keyword = ref('')
const onlyInStock = ref(false)

// a setup store has no underlying state(), so write it yourself
function $reset() {
keyword.value = ''
onlyInStock.value = false
}

return { keyword, onlyInStock, $reset }
})

What happens if you call it without writing one varies by build. The stores/counter.ts above does not return a $reset, so it falls here.

BuildCalling $reset()
DevelopmentThrows 🍍: Store "counter" is built using the setup syntax and does not implement $reset().
ProductionDoes nothing

The implementation is shaped as isOptionsStore ? ... : NODE_ENV !== 'production' ? () => { throw ... } : noop, so the throw is replaced by an empty function in production artifacts. Going unnoticed in development and turning into "pressing the reset button does nothing" in production is what makes this shape dangerous. If your policy is to write $reset yourself, put it in whenever you create a store.

Note that an option store's $reset() calls the function form of $patch() internally. From $subscribe it arrives as 'patch function', so a reset cannot be told apart.

Across stores​

You can call another store from inside a store. Just call use...Store() inside setup.

export const useReportStore = defineStore('report', () => {
const counter = useCounterStore()
const label = computed(() => `${counter.name}: ${counter.count}`)
return { label, bump: counter.increment }
})

There is no need to build parent-child containers as with Vuex modules. Lay stores out flat and import whichever you need directly. One store per file under src/stores/ is the standard arrangement.

The string in defineStore()'s first argument ('report') is the store's id. It becomes the display name in devtools and the key in pinia.state.value, so keeping it in step with the file name makes things easier to follow.

Summary​

  • Pinia puts state outside the component tree. Plug createPinia() into the app and pull stores defined with defineStore() out anywhere with use...Store()
  • There are two ways to write one: option stores and setup stores. The official docs recommend neither and tell you to pick the one you are used to. Setup stores let you write anything you can write in <script setup> (a watch() in the definition, local variables, writable computeds, composables using inject())
  • Return every piece of state from a setup store. A ref you forgot to return is alive inside the store but is undefined through the store and absent from $state. SSR, devtools, and plugins all look at $state, so under SSR an unreturned value is not restored and a computed quietly gives a different number in the browser after hydration
  • const { count } = store does not follow changes. storeToRefs() gives refs that do. It picks up only refs and reactive things, excluding actions and plain values. Actions are bound to the store, so destructuring them straight from store works. Whether writes to the resulting ref land depends on the original definition — ref state lands, while option-store getters and wholesale assignment to reactive() state do not
  • $patch() has an object form and a function form. The function form is for changes hard to express as a diff, such as array operations. The object form merges nested objects recursively, but only when both sides are plain objects (arrays, Dates, and reactive() are replaced)
  • Assignment to $state is also a function-form $patch(), but it is Object.assign underneath, so it merges only one level. Nested values are replaced wholesale
  • $subscribe()'s mutation.type is one of 'direct' / 'patch object' / 'patch function'. payload is present only for 'patch object'. 'patch function' covers assignment to $state and an option store's $reset() as well as $patch(fn)
  • $patch notifies on the spot and direct assignment notifies on the next tick. As a result, at the default flush, a direct assignment in the same tick as a $patch is not notified. A subscription given { flush: 'sync' } receives both
  • $subscribe binds by default to the effect scope at the moment of the call. Called in a component it is removed on unmount; called in a plugin it is removed by $dispose(). Pass { detached: true } to keep it. Pass the same function twice and the second is discarded, and its return value cannot unsubscribe. mutation.events is undefined in production builds
  • $reset() works by default only in option stores. Call it in a setup store without writing one and development throws while production does nothing. Return your own $reset. An option store's $reset() uses Object.assign, so keys absent from state() survive, and it arrives at $subscribe as 'patch function'
  • You can call another store from inside a store. Do not nest modules; lay stores out flat, one per file
Related references