Tutorial de Pinia: a gestão de estado global no Vue 3

Tutorial de Pinia: a gestão de estado global no Vue 3
Resposta rápida

O Pinia é a biblioteca de gestão de estado recomendada para o Vue 3, o Vuex está em manutenção. Um store declara-se com defineStore, usa-se chamando usePanierStore() em qualquer componente, e desestrutura-se com storeToRefs para não perder a reatividade.

Fazer subir um evento de um componente para o seu pai funciona bem em um ou dois níveis. Para lá disso, passas a vida a reemitir eventos e a fazer descer props por componentes que não fazem nada com elas. O Pinia resolve o problema: um estado declarado uma vez, legível e alterável a partir de qualquer componente.

Pinia ou Vuex?

A pergunta já não se coloca. O Vuex está em manutenção e a documentação oficial do Vue remete para o Pinia, que passou a ser a biblioteca de gestão de estado recomendada. Num projeto novo, escolhe o Pinia sem hesitar. Num projeto em Vuex que funciona, a migração não tem urgência nenhuma: faz-se store a store, os dois convivem.

O que o Pinia muda na prática: acabaram-se as mutations, só há actions; acabaram-se os módulos aninhados, cada store é um módulo, e a tipagem funciona sem contorcionismos.

Instalar

bash
npm install pinia

Depois liga o plugin à aplicação:

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

Num projeto novo, o gerador oficial trata disso: responde «sim» ao Pinia durante o npm create vue@latest.

Escrever um store

Um store declara-se com defineStore. O primeiro argumento é um identificador único, usado pelas ferramentas de desenvolvimento. O segundo pode assumir duas formas, a forma setup é a que as ferramentas oficiais geram e a que mais se parece com um 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)
  )

  // actions: funções
  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 }
})

Tudo o que a função devolve passa a ser público. O que ela não devolve fica privado ao store: é assim que lá guardas um contador interno ou uma função utilitária.

A convenção de nomes é useQuelqueChoseStore, como num composable.

A forma options

A outra escrita retoma a estrutura do Vuex, sem as 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 })
    }
  }
})

As duas formas são equivalentes e permutáveis. A forma options facilita a chegada a partir do Vuex, a forma setup dá mais liberdade, sobretudo para usar um composable dentro do store.

Usar o store

Chama a função no componente. A instância é única: dois componentes que chamem usePanierStore() obtêm exatamente o mesmo 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 e actions alcançam-se diretamente no objeto, sem passar por nenhum intermediário.

storeToRefs, ou a reatividade perdida

É o erro mais frequente com o Pinia. Desestruturar o store dá valores congelados:

javascript
const panier = usePanierStore()

const { total } = panier               // ✗ número congelado, não voltará a mudar
panier.ajouter({ id: 1, prix: 7 })
console.log(total)                     // 0

O storeToRefs extrai o estado e os getters mantendo a reatividade:

javascript
import { storeToRefs } from 'pinia'

const panier = usePanierStore()
const { total, lignes } = storeToRefs(panier)   // ✓ refs
const { ajouter, retirer } = panier             // ✓ as actions desestruturam-se

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

Guarda a regra: o estado e os getters passam pelo storeToRefs, as actions desestruturam-se diretamente. As actions são simples funções ligadas ao store, não perdem nada.

Alterar o estado

Três maneiras, por ordem de preferência.

Uma action para toda a lógica de negócio. É o caso normal: a regra fica escrita uma vez, dentro do store.

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

O $patch para alterar várias entradas numa só operação, o que produz uma única entrada nas ferramentas de desenvolvimento:

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

// forma função, para os arrays
panier.$patch((state) => {
  state.lignes.push(nouvelleLigne)
  state.derniereMaj = Date.now()
})

A atribuição direta é permitida, ao contrário do Vuex, mas reserva-a para os casos triviais:

javascript
panier.codePromo = 'RENTREE'
O $reset não existe num store setup

Num store escrito na forma options, o panier.$reset() repõe o estado inicial. Num store na forma setup, o Pinia não conhece esse estado inicial: a chamada lança um erro. Escreve a tua própria action de reposição, como a função vider() acima.

Observar as alterações

O $subscribe é chamado a cada mutação do estado. É o gancho certo para persistir o carrinho:

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

Para uma necessidade de persistência mais ampla, um plugin Pinia faz o trabalho em todos os stores. Um plugin é uma função que recebe o contexto e 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)

Testar um store

Um store testa-se sem montar componente nenhum. Basta ativar uma instância Pinia nova antes de cada teste, para partir de um estado limpo:

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

Sem setActivePinia, a chamada ao store falha com a mensagem « “getActivePinia()” was called but there was no active Pinia. Are you trying to use a store before calling “app.use(pinia)”? ».

Quando não usar o Pinia

Um store não é gratuito: acrescenta um ficheiro, uma indireção e mais um estado global. Justifica-se quando um dado é usado por componentes distantes, ou quando tem de sobreviver à desmontagem de um componente.

Para dados que vêm de um serviço em tempo real, vê antes o Firebase e o VueFire, cujas ligações já são reativas.

Para o estado local de um formulário, um ref chega. Para um estado partilhado entre um pai e o seu filho direto, as props e os eventos fazem o trabalho. Para cache de dados do servidor, uma ferramenta dedicada como o TanStack Query responde melhor à necessidade.

Se estás a descobrir o Vue agora, começa pelo tutorial de Vue 3 para principiantes: o Pinia só ganha sentido depois de dominares os componentes e os eventos.

Erros frequentes

Desestruturar o store const { total } = panier dá um valor congelado. Passa por storeToRefs(panier) para o estado e os getters, as actions, essas, desestruturam-se diretamente.n$reset num store setup | O Pinia não conhece o estado inicial de um store escrito na forma setup: a chamada lança um erro. Escreve a tua própria action de reposição.
Chamar um store fora de um componente sem Pinia ativa Aparece o erro «getActivePinia was called with no active Pinia». Num teste, chama setActivePinia(createPinia()) antes de cada caso.
Meter todo o estado da aplicação num store Um ref local chega para um formulário, e as props ou os eventos para um pai e o seu filho direto.

JavaScriptPiniaVue.js

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.