En esta página

Pinia — gestión de estado global

14 min lectura TextoCap. 4 — Estado y datos

¿Qué es Pinia?

Pinia es la biblioteca oficial de gestión de estado para Vue 3. Es el sucesor de Vuex y fue creada por Eduardo San Martín Morote, miembro del equipo core de Vue. Pinia está basada completamente en la Composition API y ofrece:

  • TypeScript nativo: Tipos inferidos sin decoradores ni configuración adicional
  • DevTools: Integración completa con Vue DevTools
  • Modular: Cada store es independiente, sin módulos anidados
  • Ligera: ~1KB (gzipped)
  • SSR: Soporte completo para renderizado en servidor

Instalar y configurar Pinia

npm install pinia
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia()) // Registrar Pinia antes de montar
app.mount('#app')

defineStore() — crear un store

Cada store se define con defineStore(). El primer argumento es un ID único (el nombre del store):

import { defineStore } from 'pinia'

export const useProductosStore = defineStore('productos', /* ... */)
//           ↑ Convención: 'use' + nombre en PascalCase + 'Store'
//                                                              ↑ ID único del store

Setup Stores — la sintaxis recomendada

Los Setup Stores usan la misma sintaxis que script setup:

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

interface Producto {
  id: number
  nombre: string
  precio: number
  stock: number
}

export const useProductosStore = defineStore('productos', () => {
  // STATE — refs y reactives
  const productos = ref<Producto[]>([])
  const filtro = ref('')
  const cargando = ref(false)

  // GETTERS — computed
  const productosFiltrados = computed(() =>
    productos.value.filter(p =>
      p.nombre.toLowerCase().includes(filtro.value.toLowerCase())
    )
  )

  const totalProductos = computed(() => productos.value.length)

  const hayStock = computed(() =>
    (id: number) => productos.value.find(p => p.id === id)?.stock ?? 0
  )

  // ACTIONS — funciones async o síncronas
  async function cargarProductos(): Promise<void> {
    cargando.value = true
    try {
      const res = await fetch('/api/productos')
      productos.value = await res.json() as Producto[]
    } finally {
      cargando.value = false
    }
  }

  function agregarProducto(producto: Omit<Producto, 'id'>): void {
    const id = Math.max(0, ...productos.value.map(p => p.id)) + 1
    productos.value.push({ id, ...producto })
  }

  function eliminarProducto(id: number): void {
    productos.value = productos.value.filter(p => p.id !== id)
  }

  return {
    productos,
    filtro,
    cargando,
    productosFiltrados,
    totalProductos,
    hayStock,
    cargarProductos,
    agregarProducto,
    eliminarProducto,
  }
})

Usar el store en componentes

<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useProductosStore } from '@/stores/productos'

const store = useProductosStore()

// storeToRefs para estado y getters (mantiene reactividad)
const { productos, filtro, cargando, productosFiltrados } = storeToRefs(store)

// Actions directamente del store (son funciones normales, no pierden contexto)
const { cargarProductos, eliminarProducto } = store

onMounted(cargarProductos)
</script>

<template>
  <div>
    <input v-model="filtro" placeholder="Filtrar productos..." />
    <p v-if="cargando">Cargando...</p>
    <ul v-else>
      <li v-for="p in productosFiltrados" :key="p.id">
        {{ p.nombre }} — ${{ p.precio }}
        <button type="button" @click="eliminarProducto(p.id)">Eliminar</button>
      </li>
    </ul>
  </div>
</template>

Plugins de Pinia — persistencia

Un plugin muy común es pinia-plugin-persistedstate para guardar el estado en localStorage:

npm install pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// En el store
export const useCarritoStore = defineStore('carrito', () => {
  const items = ref<ItemCarrito[]>([])
  return { items }
}, {
  persist: true,  // Guardar en localStorage automáticamente
})

// O con configuración detallada
export const usePreferenciasStore = defineStore('preferencias', () => {
  const tema = ref<'claro' | 'oscuro'>('claro')
  const idioma = ref('es')
  return { tema, idioma }
}, {
  persist: {
    storage: localStorage,
    pick: ['tema'],  // Solo persistir el tema
  }
})

Comunicación entre stores

Los stores de Pinia pueden usar otros stores directamente:

// stores/pedidos.ts
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
import { useCarritoStore } from './carrito'

export const usePedidosStore = defineStore('pedidos', () => {
  const auth = useAuthStore()
  const carrito = useCarritoStore()

  async function realizarPedido(): Promise<void> {
    if (!auth.estaAutenticado) throw new Error('Debes iniciar sesión')

    await fetch('/api/pedidos', {
      method: 'POST',
      body: JSON.stringify({
        usuarioId: auth.usuario?.id,
        items: carrito.items,
        total: carrito.total,
      }),
    })

    carrito.vaciar()
  }

  return { realizarPedido }
})

$reset() — restaurar el estado inicial

Los Options Stores incluyen $reset() automáticamente. En Setup Stores, debes implementarlo manualmente:

export const useFormStore = defineStore('form', () => {
  const nombre = ref('')
  const email = ref('')
  const mensaje = ref('')

  function $reset(): void {
    nombre.value = ''
    email.value = ''
    mensaje.value = ''
  }

  return { nombre, email, mensaje, $reset }
})

$subscribe — observar cambios del store

const store = useCarritoStore()

// Se ejecuta cada vez que el estado del store cambia
store.$subscribe((mutacion, estado) => {
  console.log('Store cambió:', mutacion.type, estado)
  localStorage.setItem('carrito', JSON.stringify(estado))
})

$patch — actualizar múltiples propiedades

const store = useCarritoStore()

// Actualizar múltiples propiedades en una sola transacción
store.$patch({
  descuento: 0.15,
  codigoPromo: 'VERANO25',
})

// Con función (para lógica más compleja)
store.$patch((state) => {
  state.items.forEach(item => {
    if (item.categoria === 'electronica') item.precio *= 0.9
  })
})

Práctica

  1. Store de tareas: Crea un useTareasStore con Setup Store. Incluye estado para la lista de tareas y el filtro activo. Implementa getters para tareas filtradas y conteo. Añade persistencia con pinia-plugin-persistedstate.
  2. Store de autenticación: Implementa useAuthStore con las acciones login() y logout(). Integra con el guard global de Vue Router para proteger rutas. Usa storeToRefs correctamente en los componentes.
  3. Comunicación entre stores: Crea un store de notificaciones useNotificacionesStore que otros stores puedan usar. El useAuthStore llama a notificaciones.push('Bienvenido') tras un login exitoso.

En la siguiente lección profundizaremos en formularios y v-model para construir formularios reactivos.

Setup stores vs Options stores — elige uno y sé consistente
Ambos estilos son completamente equivalentes en funcionalidad. Los Setup stores son más flexibles y tienen mejor soporte de TypeScript (inferencia natural). Los Options stores son más estructurados y familiares si vienes de Vuex. El equipo de Pinia recomienda Setup stores para proyectos nuevos con TypeScript.
storeToRefs — obligatorio para desestructurar estado
Si desestructuras el store directamente (const { items } = carrito), pierdes la reactividad. Usa siempre storeToRefs para desestructurar estado y getters. Las actions son funciones normales y no necesitan storeToRefs.
Pinia DevTools — depuración poderosa
Con Vue DevTools instalado en tu navegador, Pinia proporciona una pestaña dedicada que muestra el estado actual de todos los stores, permite modificar el estado directamente, viaja en el tiempo por el historial de cambios y muestra qué acciones se ejecutaron. Instala la extensión Vue.js devtools en Chrome o Firefox.
typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

interface Usuario {
  id: number
  nombre: string
  email: string
  rol: 'admin' | 'usuario'
}

// Setup store — sintaxis de Composition API (recomendada)
export const useAuthStore = defineStore('auth', () => {
  // state — refs y reactives
  const usuario = ref<Usuario | null>(null)
  const token = ref<string | null>(null)
  const cargando = ref(false)

  // getters — computed
  const estaAutenticado = computed(() => usuario.value !== null)
  const esAdmin = computed(() => usuario.value?.rol === 'admin')
  const nombreUsuario = computed(() => usuario.value?.nombre ?? 'Invitado')

  // actions — funciones
  async function login(email: string, password: string): Promise<void> {
    cargando.value = true
    try {
      const res = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })
      if (!res.ok) throw new Error('Credenciales incorrectas')
      const datos = await res.json() as { usuario: Usuario; token: string }
      usuario.value = datos.usuario
      token.value = datos.token
    } finally {
      cargando.value = false
    }
  }

  function logout(): void {
    usuario.value = null
    token.value = null
  }

  return {
    // Exponer estado, getters y acciones
    usuario,
    token,
    cargando,
    estaAutenticado,
    esAdmin,
    nombreUsuario,
    login,
    logout,
  }
})
import { defineStore } from 'pinia'

interface ItemCarrito {
  id: number
  nombre: string
  precio: number
  cantidad: number
}

// Options store — sintaxis similar a Options API de Vue
export const useCarritoStore = defineStore('carrito', {
  state: () => ({
    items: [] as ItemCarrito[],
    descuento: 0,
  }),

  getters: {
    total: (state) =>
      state.items.reduce((sum, i) => sum + i.precio * i.cantidad, 0),

    totalConDescuento(): number {
      return this.total * (1 - this.descuento)
    },

    cantidadItems: (state) =>
      state.items.reduce((sum, i) => sum + i.cantidad, 0),
  },

  actions: {
    agregarItem(item: Omit<ItemCarrito, 'cantidad'>): void {
      const existente = this.items.find(i => i.id === item.id)
      if (existente) {
        existente.cantidad++
      } else {
        this.items.push({ ...item, cantidad: 1 })
      }
    },

    eliminarItem(id: number): void {
      this.items = this.items.filter(i => i.id !== id)
    },

    vaciar(): void {
      this.items = []
      this.descuento = 0
    },
  },
})