Skip to content
Deep divePinia

Pinia from the inside: state, getters and actions

A store is a reactive object that is created once and lives as long as the app. Everything else about Pinia follows from that sentence.

vuejs.school team13 May 20257 min readPinia 3.0
cartitemstotaladd()refsRef<items>Ref<total>

You can use Pinia for months by pattern-matching: defineStore, useCartStore(), read cart.lines, call cart.add(). Then a store seems to reset itself, two components disagree about the same value, or calling a store at the top of a file throws, and the pattern does not tell you why. Each has a plain cause once you know what a store is as a JavaScript object and when Pinia creates it.

The examples use Pinia 3.0 on Vue 3.5, written as setup stores.

A store outlives components

A component's state is born in its setup and dies when the component unmounts. A store's state does neither.

createPinia() creates one pinia instance. It holds a reactive object, pinia.state, with every store's state by id, and an internal map of the stores themselves. app.use(pinia) installs it in the app. When a store is first used, Pinia runs its setup function once, puts the state into pinia.state.value['cart'], and adds the resulting store object to the map. From then on it stays: components mount and unmount around it, and only a page load or an explicit cart.$dispose() removes it.

That explains the first surprise. If the cart "resets" when you move to another page, either it was never in the store (it was a local ref in a component that unmounted) or the page itself reloaded, as it does after a plain <a href> link (not a <RouterLink>) or a refresh, and the whole app started again.

It also explains the second. Every component that uses the cart holds a reference to the same object, so when two seem to disagree about cart.total, one of them copied the value out: const { total } = cart reads the number once and never updates. Read cart.total directly, or destructure with storeToRefs(cart).

State, getters and actions in a setup store

A setup store is a function. Inside it you write ordinary Composition API code, and Pinia reads what you return:

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

export interface Line {
  id: number
  name: string
  price: number
  qty: number
}

export const useCartStore = defineStore('cart', () => {
  const lines = ref<Line[]>([])

  const total = computed(() => lines.value.reduce((sum, l) => sum + l.price * l.qty, 0))
  const count = computed(() => lines.value.reduce((sum, l) => sum + l.qty, 0))

  function add(line: Omit<Line, 'qty'>) {
    const existing = lines.value.find((l) => l.id === line.id)
    if (existing) existing.qty++
    else lines.value.push({ ...line, qty: 1 })
  }

  function clear() {
    lines.value = []
  }

  return { lines, total, count, add, clear }
})
ref()
StateThe data the store holds.
computed()
GetterA cached value derived from state.
function
ActionSomething you call to change state.

Pinia decides which part each returned thing is by looking at what it is. A ref or a reactive object becomes state. A computed becomes a getter. A function becomes an action. The names you return are the names on the store: cart.lines, cart.total, cart.add().

Return everything that is state. A ref you keep private is still reactive and still works inside the store, but Pinia does not know about it, so it is missing from devtools, from server-side rendering and from plugins. If you need a value hidden from the outside, return it anyway and treat it as private by convention.

How useCartStore() finds the same store every time

defineStore('cart', setup) does not create a store. It returns a function, the one you named useCartStore, and remembers the id and the setup function. The store is created the first time that function is called.

Each call to useCartStore() does this, in order:

  1. It finds the active Pinia instance. Inside a component it uses inject() to get the one installed with app.use(pinia). Outside a component it uses the instance Pinia marked active: the one most recently installed or passed to setActivePinia().
  2. It looks in that instance's map of stores for the id cart.
  3. If the store is there, it returns it. If not, it runs your setup function once, wraps the result into a store object, registers it in the map, and returns that.

So "the same instance everywhere" is a lookup in a map keyed by id, and the setup function is a factory that runs once per Pinia instance. On the server, where every request gets its own Pinia instance, the same code runs once per request, which is what stops one visitor seeing another visitor's cart.

Why calling a store at the top of a file throws

Step 1 needs an active Pinia, and that is where beginners get caught. This file calls the store at its top level:

src/api/client.tsBreaks
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()

export function authHeaders() {
  return { Authorization: `Bearer ${auth.token}` }
}

Top-level code runs when a file is first imported, and every file that main.ts imports, directly or through another file, runs before main.ts reaches app.use(pinia). In development the console shows [🍍]: "getActivePinia()" was called but there was no active Pinia. Are you trying to use a store before calling "app.use(pinia)"? A production build skips that check and fails with a vaguer TypeError instead.

The fix is to move the call inside the function, so it runs when something calls authHeaders(), long after Pinia is installed:

src/api/client.tsWorks
import { useAuthStore } from '@/stores/auth'

export function authHeaders() {
  const auth = useAuthStore()
  return { Authorization: `Bearer ${auth.token}` }
}

Calling useAuthStore() on every call is cheap: after the first time it is a lookup in the map.

How getters cache

A getter is a computed, and a computed caches. cart.total runs its function once, remembers the result, and records that it depends on lines and on every price and qty it read. Reading cart.total ten times in a template runs the reduce once. Changing a qty marks the computed stale, and the next read runs it again. Nothing runs if nobody reads.

Two consequences follow. First, a getter can use another getter, and the dependency tracking still works through both: const isEmpty = computed(() => count.value === 0) depends on count, which depends on lines, so adding a line updates both.

Second, a getter that takes an argument cannot cache, because a computed has no arguments. The pattern is a getter that returns a function, written inside the setup function and returned like any getter:

const lineById = computed(() => (id: number) => lines.value.find((l) => l.id === id))

The outer computed caches the function, not the result, so cart.lineById(3) searches the array every time. That is usually fine; if it is not, build a Map inside the computed and look up in that.

Actions can be async and call each other

An action is a plain function, so it can be async, it can throw, and it can call other actions by name, because they are in the same scope. This one goes inside the cart's setup function and is returned with the rest:

async function checkout() {
  const snapshot = lines.value.map((l) => ({ ...l }))
  clear()
  try {
    const res = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ lines: snapshot }),
    })
    if (!res.ok) throw new Error(`Order failed with status ${res.status}`)
  } catch (error) {
    lines.value = snapshot
    throw error
  }
}

fetch rejects only when the request fails, for example on a network error. A response such as a 500 still resolves, so the res.ok check turns it into an error. The catch then restores the lines and rethrows, so the component that called checkout() can show a message.

clear() is called by name, not through this or the store, so the call skips Pinia's action wrapper. The wrapper sits on the store object and tells $onAction subscribers, devtools among them, when an action starts and finishes. So when a component calls cart.checkout(), those subscribers see checkout and no separate clear.

The $ methods: $patch, $subscribe and $reset

Every store carries a few methods that start with $. These three are worth knowing early.

$patch applies several changes as one mutation. You can pass an object to merge or a function to mutate:

cart.$patch({ lines: [] })

cart.$patch((state) => {
  state.lines.push({ id: 9, name: 'Tea', price: 3, qty: 1 })
  state.lines[0].qty = 2
})

Direct assignment, cart.lines = [], also works for state, and for a single change it is the clearest. $patch matters when several changes belong together: subscribers receive them as one mutation with one type, and the devtools timeline shows one entry instead of one per change.

$subscribe runs a callback after the store's state changes. It receives a mutation object and the new state. mutation.type is 'direct' for assignments, 'patch object' or 'patch function' for the two forms of $patch, and mutation.storeId names the store. Like a watch, the callback runs after the current code finishes, so several direct assignments made together arrive as one call; pass { flush: 'sync' } for one call per change.

The common use is persistence. This main.ts saves the cart whenever it changes:

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

const app = createApp(App)
app.use(createPinia())

const cart = useCartStore()
cart.$subscribe((mutation, state) => {
  localStorage.setItem('cart', JSON.stringify(state.lines))
})

app.mount('#app')

useCartStore() works here, outside any component, because app.use() has just made this Pinia the active one. A subscription made inside a component is removed when the component unmounts. To keep it, pass { detached: true } as the second argument, or create it in main.ts as above, where there is no component to unmount.

Setup stores do not get $reset() for free. In development, calling it throws 🍍: Store "cart" is built using the setup syntax and does not implement $reset(). In a production build it does nothing at all. If you need a reset, write a reset action that assigns the initial values, and return it like any other action.

What the Pinia tab in devtools shows

Open the Vue devtools, pick the Pinia tab, and select the cart store. The inspector shows two of the three parts. State lists lines and lets you edit a value in place; the app reacts as if the code had changed it. Getters lists total and count with their current values, recomputed as you edit state.

Actions appear in the timeline instead. It records each action called on the store, with its arguments, and every mutation with its type, which is how you find out which action emptied the cart.

Because the inspector is built from what the store returned, a private ref is invisible, and so is a getter you did not return.

Check it stuck3 questions from the Setup stores quiz.
Practice now
Was this helpful?

Keep reading