Pinia tutorial: global state management in a Vue 3 app

Pinia tutorial: global state management in a Vue 3 app
Quick answer

Pinia is the recommended state management library for Vue 3, Vuex is in maintenance mode. A store is declared with defineStore, used by calling usePanierStore() in any component, and destructured with storeToRefs so that reactivity is not lost.

Sending an event up from a component to its parent works fine across one or two levels. Beyond that, you spend your time re-emitting events and passing props down through components that have no use for them. Pinia solves that problem: state declared once, readable and writable from any component.

Pinia or Vuex?

The question no longer arises. Vuex is in maintenance mode and the official Vue documentation points to Pinia, which has become the recommended state management library. For a new project, take Pinia without a second thought. For a working Vuex project, migrating is in no way urgent: it happens store by store, and the two live side by side.

What Pinia changes in practice: no more mutations, only actions; no more nested modules, since every store is a module, and typing that works without contortions.

Installing

bash
npm install pinia

Then plug the plugin into the application:

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

const app = createApp(App)

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

On a new project the official generator does it for you: answer “yes” to Pinia during npm create vue@latest.

Writing a store

A store is declared with defineStore. The first argument is a unique identifier, used by the devtools. The second can take two forms, the setup form is the one the official tooling generates, and the one that looks most like a component.

src/stores/panier.js
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const usePanierStore = defineStore('panier', () => {
  // state: refs
  const lignes = ref([])

  // getters: computed values
  const total = computed(() =>
    lignes.value.reduce((somme, l) => somme + l.prix * l.quantite, 0)
  )
  const nombreArticles = computed(() =>
    lignes.value.reduce((somme, l) => somme + l.quantite, 0)
  )

  // actions: functions
  function ajouter(produit) {
    const ligne = lignes.value.find((l) => l.id === produit.id)
    if (ligne) {
      ligne.quantite += 1
      return
    }
    lignes.value.push({ ...produit, quantite: 1 })
  }

  function retirer(id) {
    lignes.value = lignes.value.filter((l) => l.id !== id)
  }

  function vider() {
    lignes.value = []
  }

  return { lignes, total, nombreArticles, ajouter, retirer, vider }
})

Everything the function returns becomes public. Whatever it does not return stays private to the store: that is how you keep an internal counter or a helper function in there.

The naming convention is useQuelqueChoseStore, like a composable.

The options form

The other way of writing it keeps the Vuex structure, minus the mutations:

src/stores/panier.js
export const usePanierStore = defineStore('panier', {
  state: () => ({ lignes: [] }),
  getters: {
    total: (state) => state.lignes.reduce((s, l) => s + l.prix * l.quantite, 0)
  },
  actions: {
    ajouter(produit) {
      this.lignes.push({ ...produit, quantite: 1 })
    }
  }
})

Both forms are equivalent and interchangeable. The options form makes the move from Vuex easier, the setup form gives you more freedom, in particular for using a composable inside the store.

Using the store

Call the function in the component. The instance is unique: two components calling usePanierStore() get exactly the same object.

src/components/Panier.vue
<script setup>
import { usePanierStore } from '@/stores/panier'

const panier = usePanierStore()
</script>

<template>
  <p>{{ panier.nombreArticles }} article(s) — {{ panier.total }} €</p>

  <ul>
    <li v-for="ligne in panier.lignes" :key="ligne.id">
      {{ ligne.nom }} × {{ ligne.quantite }}
      <button @click="panier.retirer(ligne.id)">Retirer</button>
    </li>
  </ul>

  <button @click="panier.vider()">Vider</button>
</template>

State, getters and actions are reached directly on the object, with nothing in between.

storeToRefs, or how reactivity gets lost

This is the most frequent mistake with Pinia. Destructuring the store gives you frozen values:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ frozen number, will never change again
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs pulls out the state and the getters while keeping reactivity:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ refs
const { ajouter, retirer } = panier             // ✓ actions are destructured

panier.ajouter({ id: 1, prix: 7 })
console.log(total.value)                        // 7

Remember the rule: state and getters go through storeToRefs, actions are destructured directly. Actions are plain functions bound to the store, they lose nothing.

Changing the state

Three ways, in order of preference.

An action for any business logic. This is the normal case: the rule is written once, in the store.

javascript
panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })

$patch to change several entries in a single operation, which produces only one entry in the devtools:

javascript
panier.$patch({ lignes: [], codePromo: null })

// function form, for arrays
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

Direct assignment is allowed, unlike in Vuex, but keep it for trivial cases:

javascript
panier.codePromo = 'RENTREE'
$reset does not exist on a setup store

On a store written in the options form, panier.$reset() restores the initial state. On a setup store, Pinia does not know that initial state: the call throws an error. Write your own reset action, like the vider() function above.

Watching for changes

$subscribe is called on every mutation of the state. This is the hook for persisting the cart:

javascript
panier.$subscribe((mutation, state) => {
  localStorage.setItem('panier', JSON.stringify(state.lignes))
})

For a broader persistence need, a Pinia plugin does the job across every store. A plugin is a function that receives the context and extends each store:

src/stores/plugin-persistance.js
export function persistance({ store }) {
  const sauvegarde = localStorage.getItem(store.$id)
  if (sauvegarde) {
    store.$patch(JSON.parse(sauvegarde))
  }

  store.$subscribe((_, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}
src/main.js
const pinia = createPinia()
pinia.use(persistance)

Testing a store

A store can be tested without mounting a component. All you need is to activate a fresh Pinia instance before each test, so you start from a clean state:

src/stores/__tests__/panier.spec.js
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { usePanierStore } from '../panier.js'

describe('store panier', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('cumule les quantités du même produit', () => {
    const panier = usePanierStore()
    panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })
    panier.ajouter({ id: 2234, nom: 'T-Shirt', prix: 25 })

    expect(panier.lignes).toHaveLength(1)
    expect(panier.nombreArticles).toBe(2)
    expect(panier.total).toBe(50)
  })
})

Without setActivePinia, calling the store fails with the message “”getActivePinia()” was called but there was no active Pinia. Are you trying to use a store before calling “app.use(pinia)”?”.

When not to use Pinia

A store is not free: it adds a file, a level of indirection and one more piece of global state. It earns its place when a piece of data is used by components far apart in the tree, or has to survive a component being unmounted.

For data coming from a real-time service, look at Firebase and VueFire instead, whose bindings are already reactive.

For the local state of a form, a ref is enough. For state shared between a parent and its direct child, props and events do the job. For caching server data, a dedicated tool such as TanStack Query fits the need better.

If you are new to Vue, start with the Vue 3 tutorial for beginners: Pinia only makes full sense once components and events are second nature.

Common errors

Destructuring the store const { total } = panier gives a frozen value. Go through storeToRefs(panier) for state and getters, actions, on the other hand, are destructured directly.n$reset on a setup store | Pinia does not know the initial state of a store written in the setup form: the call throws an error. Write your own reset action.
Calling a store outside a component with no active Pinia The error “getActivePinia was called with no active Pinia” shows up. In a test, call setActivePinia(createPinia()) before each case.
Putting the whole application state in a store A local ref is enough for a form, and props or events for a parent and its direct child.

JavaScriptPiniaVue.js

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.