
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
npm install piniaThen plug the plugin into the application:
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.
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:
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.
<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:
const panier = usePanierStore()
const { total } = panier // ✗ frozen number, will never change again
panier.ajouter({ id: 1, prix: 7 })
console.log(total) // 0storeToRefs pulls out the state and the getters while keeping reactivity:
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) // 7Remember 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.
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:
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:
panier.codePromo = 'RENTREE'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:
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:
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))
})
}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:
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
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.setActivePinia(createPinia()) before each case.

