Tutorial de Pinia: la gestión del estado global en Vue 3

Tutorial de Pinia: la gestión del estado global en Vue 3
Respuesta rápida

Pinia es la librería de gestión del estado recomendada para Vue 3, Vuex está en modo mantenimiento. Un store se declara con defineStore, se usa llamando a usePanierStore() desde cualquier componente y se desestructura con storeToRefs para no perder la reactividad.

Hacer subir un evento de un componente a su padre funciona bien en uno o dos niveles. Más allá, te pasas el rato reemitiendo eventos y bajando props a través de componentes que no hacen nada con ellas. Pinia resuelve ese problema: un estado declarado una sola vez, legible y modificable desde cualquier componente.

¿Pinia o Vuex?

La pregunta ya no se plantea. Vuex está en modo mantenimiento y la documentación oficial de Vue remite a Pinia, que se ha convertido en la librería de gestión del estado recomendada. Para un proyecto nuevo, elige Pinia sin dudarlo. Para un proyecto con Vuex que funciona, la migración no corre prisa: se hace store a store y ambos conviven.

Lo que Pinia cambia en la práctica: se acabaron las mutaciones, solo hay acciones, se acabaron los módulos anidados, cada store es un módulo, y un tipado que funciona sin contorsiones.

Instalar

bash
npm install pinia

Después conecta el plugin a la aplicación:

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')

En un proyecto nuevo, el generador oficial se encarga: responde «sí» a Pinia durante npm create vue@latest.

Escribir un store

Un store se declara con defineStore. El primer argumento es un identificador único que usan las herramientas de desarrollo. El segundo admite dos formas, la forma setup es la que genera el utillaje oficial y la que más se parece a un componente.

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

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

  // getters: computed
  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)
  )

  // acciones: funciones
  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 }
})

Todo lo que la función devuelve pasa a ser público. Lo que no devuelve queda privado dentro del store: así es como se guarda ahí un contador interno o una función de utilidad.

La convención de nombres es useQuelqueChoseStore, como en un composable.

La forma options

La otra escritura retoma la estructura de Vuex, sin las mutaciones:

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 })
    }
  }
})

Las dos formas son equivalentes e intercambiables. La forma options facilita la llegada desde Vuex, la forma setup da más libertad, sobre todo para usar un composable dentro del store.

Usar el store

Llama a la función dentro del componente. La instancia es única: dos componentes que llaman a usePanierStore() obtienen exactamente el mismo objeto.

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>

Estado, getters y acciones se alcanzan directamente sobre el objeto, sin ningún intermediario.

storeToRefs, o la reactividad perdida

Es el error más frecuente con Pinia. Desestructurar el store da valores congelados:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ número congelado, ya no cambiará
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

storeToRefs extrae el estado y los getters conservando la reactividad:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ refs
const { ajouter, retirer } = panier             // ✓ las acciones sí se desestructuran

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

Quédate con la regla: el estado y los getters pasan por storeToRefs, las acciones se desestructuran directamente. Las acciones son simples funciones ligadas al store, no pierden nada.

Modificar el estado

Tres maneras, por orden de preferencia.

Una acción para toda la lógica de negocio. Es el caso normal: la regla se escribe una sola vez, en el store.

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

$patch para modificar varias entradas en una sola operación, lo que genera una única entrada en las herramientas de desarrollo:

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

// forma función, para los arrays
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

La asignación directa está permitida, al contrario que en Vuex, pero resérvala para los casos triviales:

javascript
panier.codePromo = 'RENTREE'
$reset no existe en un store setup

En un store escrito en forma options, panier.$reset() restaura el estado inicial. En un store en forma setup, Pinia no conoce ese estado inicial: la llamada lanza un error. Escribe tu propia acción de reinicio, como la función vider() de más arriba.

Observar los cambios

$subscribe se llama en cada mutación del estado. Es el hook para persistir el carrito:

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

Si la necesidad de persistencia es más amplia, un plugin de Pinia hace el trabajo en todos los stores. Un plugin es una función que recibe el contexto y enriquece cada 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)

Probar un store

Un store se prueba sin montar ningún componente. Solo hace falta activar una instancia de Pinia nueva antes de cada test, para partir de un estado limpio:

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)
  })
})

Sin setActivePinia, la llamada al store falla con el mensaje « «getActivePinia()» was called but there was no active Pinia. Are you trying to use a store before calling «app.use(pinia)»? ».

Cuándo no usar Pinia

Un store no sale gratis: añade un archivo, una indirección y un estado global más. Se justifica cuando un dato lo usan componentes alejados entre sí, o cuando tiene que sobrevivir al desmontaje de un componente.

Para datos que vienen de un servicio en tiempo real, mira mejor Firebase y VueFire, cuyos enlaces ya son reactivos.

Para el estado local de un formulario, basta un ref. Para un estado compartido entre un padre y su hijo directo, las props y los eventos hacen el trabajo. Para cachear datos del servidor, una herramienta dedicada como TanStack Query responde mejor a la necesidad.

Si acabas de descubrir Vue, empieza por el tutorial de Vue 3 para principiantes: Pinia cobra todo su sentido una vez asimilados los componentes y los eventos.

Errores frecuentes

Desestructurar el store const { total } = panier da un valor congelado. Pasa por storeToRefs(panier) para el estado y los getters, las acciones, en cambio, se desestructuran directamente.n$reset en un store setup | Pinia no conoce el estado inicial de un store escrito en forma setup: la llamada lanza un error. Escribe tu propia acción de reinicio.
Llamar a un store fuera de un componente sin Pinia activa Aparece el error «getActivePinia was called with no active Pinia». En un test, llama a setActivePinia(createPinia()) antes de cada caso.
Meter todo el estado de la aplicación en un store Un ref local basta para un formulario, y las props o los eventos para un padre y su hijo directo.

JavaScriptPiniaVue.js

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.