Tema
Pinia
Cuando varios componentes en distintas partes de la app necesitan los mismos datos (el usuario con sesión, el carrito, las preferencias), pasarlos por props se vuelve un caos. Pinia es la librería oficial de estado global de Vue: simple, con soporte para DevTools y TypeScript. Aprenderás a crear stores, usarlos en componentes, persistir datos y construir un store de autenticación y un carrito.
Concepto
Un store es un contenedor de estado compartido, con sus propias acciones y valores derivados. Cualquier componente puede usarlo directamente, sin importar dónde esté en el árbol.
txt
┌──────────────── useCarritoStore() ────────────────┐
│ state: items getters: total, piezas │
│ actions: agregar(), quitar(), vaciar() │
└──────▲───────────────────▲───────────────────▲────┘
│ │ │
<Navbar> 🛒 3 <TarjetaProducto> <PaginaCarrito>Analogía: es como el refrigerador de una casa. En lugar de que cada persona cargue su comida en la mochila y la pase de mano en mano (props), todos van al refrigerador común.
| Pinia | Equivale en un componente |
|---|---|
state | ref |
getters | computed |
actions | funciones |
Si creaste el proyecto con create-vue y elegiste Pinia, ya está instalado. Si no:
bash
npm i piniajs
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')Crear un store (sintaxis setup)
Pinia permite dos sintaxis. La setup se escribe igual que un componente con Composition API, así que ya la conoces:
js
// src/stores/contador.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// El primer argumento es un id único del store
export const useContadorStore = defineStore('contador', () => {
// state
const cuenta = ref(0)
// getters
const doble = computed(() => cuenta.value * 2)
// actions
function incrementar() {
cuenta.value++
}
function reiniciar() {
cuenta.value = 0
}
// Devuelve todo lo que quieras exponer
return { cuenta, doble, incrementar, reiniciar }
})vue
<!-- Cualquier componente -->
<script setup>
import { useContadorStore } from '@/stores/contador'
const contador = useContadorStore()
</script>
<template>
<p>{{ contador.cuenta }} (doble: {{ contador.doble }})</p>
<button @click="contador.incrementar">+1</button>
<button @click="contador.reiniciar">Reiniciar</button>
</template>Sintaxis Options (alternativa)
js
import { defineStore } from 'pinia'
export const useContadorStore = defineStore('contador', {
state: () => ({ cuenta: 0 }),
getters: {
doble: (state) => state.cuenta * 2
},
actions: {
incrementar() {
this.cuenta++
}
}
})Ambas funcionan igual. En este curso usamos la sintaxis setup por consistencia con <script setup>.
Desestructurar sin perder reactividad: storeToRefs
vue
<script setup>
import { storeToRefs } from 'pinia'
import { useContadorStore } from '@/stores/contador'
const contador = useContadorStore()
// ❌ Pierde la reactividad: cuenta queda como un número fijo
// const { cuenta } = contador
// ✅ storeToRefs para estado y getters
const { cuenta, doble } = storeToRefs(contador)
// ✅ Las acciones se desestructuran directamente
const { incrementar } = contador
</script>
<template>
<button @click="incrementar">{{ cuenta }} / {{ doble }}</button>
</template>Caso real: carrito de compras
js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
const ENVIO_GRATIS_DESDE = 999
const COSTO_ENVIO = 149
export const useCarritoStore = defineStore('carrito', () => {
// Carga inicial desde localStorage
const items = ref(JSON.parse(localStorage.getItem('carrito') ?? '[]'))
// Getters
const piezas = computed(() => items.value.reduce((n, i) => n + i.cantidad, 0))
const subtotal = computed(() => items.value.reduce((s, i) => s + i.precio * i.cantidad, 0))
const envio = computed(() => (subtotal.value === 0 || subtotal.value >= ENVIO_GRATIS_DESDE ? 0 : COSTO_ENVIO))
const total = computed(() => subtotal.value + envio.value)
const faltaParaEnvioGratis = computed(() => Math.max(0, ENVIO_GRATIS_DESDE - subtotal.value))
const estaEnCarrito = (id) => items.value.some((i) => i.id === id)
// Actions
function agregar(producto, cantidad = 1) {
const existente = items.value.find((i) => i.id === producto.id)
if (existente) {
existente.cantidad = Math.min(existente.cantidad + cantidad, producto.stock ?? 99)
} else {
items.value.push({
id: producto.id,
nombre: producto.nombre,
precio: producto.precio,
imagen: producto.imagen,
stock: producto.stock ?? 99,
cantidad
})
}
}
function cambiarCantidad(id, cantidad) {
const item = items.value.find((i) => i.id === id)
if (!item) return
if (cantidad <= 0) quitar(id)
else item.cantidad = Math.min(cantidad, item.stock)
}
function quitar(id) {
items.value = items.value.filter((i) => i.id !== id)
}
function vaciar() {
items.value = []
}
// Persistencia: guarda en cada cambio (deep para detectar cambios de cantidad)
watch(items, (nuevos) => localStorage.setItem('carrito', JSON.stringify(nuevos)), { deep: true })
return {
items,
piezas,
subtotal,
envio,
total,
faltaParaEnvioGratis,
estaEnCarrito,
agregar,
cambiarCantidad,
quitar,
vaciar
}
})vue
<script setup>
import { useCarritoStore } from '@/stores/carrito'
const carrito = useCarritoStore()
</script>
<template>
<RouterLink to="/carrito" class="boton" :aria-label="`Carrito con ${carrito.piezas} productos`">
🛒 <span v-if="carrito.piezas" class="globo">{{ carrito.piezas }}</span>
</RouterLink>
</template>
<style scoped>
.boton { position: relative; font-size: 1.4rem; text-decoration: none; }
.globo { position: absolute; top: -6px; right: -10px; background: #42b883; color: white; font-size: 0.7rem; font-weight: 700; border-radius: 99px; padding: 1px 6px; }
</style>vue
<script setup>
import { useCarritoStore } from '@/stores/carrito'
const props = defineProps({ producto: { type: Object, required: true } })
const carrito = useCarritoStore()
</script>
<template>
<article class="tarjeta">
<img :src="producto.imagen" :alt="producto.nombre">
<h3>{{ producto.nombre }}</h3>
<p>${{ producto.precio }}</p>
<button @click="carrito.agregar(producto)">
{{ carrito.estaEnCarrito(producto.id) ? 'Agregar otro' : 'Agregar al carrito' }}
</button>
</article>
</template>
<style scoped>
.tarjeta { border: 1px solid #e5e7eb; border-radius: 12px; padding: 1rem; }
.tarjeta img { width: 100%; aspect-ratio: 1; object-fit: contain; }
button { width: 100%; background: #42b883; color: white; border: 0; padding: 0.5rem; border-radius: 8px; cursor: pointer; }
</style>vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCarritoStore } from '@/stores/carrito'
const carrito = useCarritoStore()
const { items, subtotal, envio, total, faltaParaEnvioGratis } = storeToRefs(carrito)
const moneda = (n) => n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' })
</script>
<template>
<section class="carrito">
<h1>Tu carrito</h1>
<div v-if="!items.length" class="vacio">
<p>Tu carrito está vacío 🛒</p>
<RouterLink to="/productos">Ver productos</RouterLink>
</div>
<template v-else>
<p v-if="faltaParaEnvioGratis > 0" class="aviso">
Te faltan {{ moneda(faltaParaEnvioGratis) }} para envío gratis 🚚
</p>
<ul>
<li v-for="item in items" :key="item.id" class="item">
<img :src="item.imagen" alt="" width="56">
<span class="nombre">{{ item.nombre }}</span>
<input
type="number"
min="0"
:max="item.stock"
:value="item.cantidad"
@change="carrito.cambiarCantidad(item.id, Number($event.target.value))"
aria-label="Cantidad"
>
<span>{{ moneda(item.precio * item.cantidad) }}</span>
<button @click="carrito.quitar(item.id)" aria-label="Quitar">✕</button>
</li>
</ul>
<dl class="totales">
<dt>Subtotal</dt><dd>{{ moneda(subtotal) }}</dd>
<dt>Envío</dt><dd>{{ envio === 0 ? 'Gratis' : moneda(envio) }}</dd>
<dt><strong>Total</strong></dt><dd><strong>{{ moneda(total) }}</strong></dd>
</dl>
<button class="vaciar" @click="carrito.vaciar">Vaciar carrito</button>
</template>
</section>
</template>
<style scoped>
.carrito { max-width: 640px; margin: 0 auto; }
.aviso { background: #f0fdf4; padding: 0.5rem 1rem; border-radius: 8px; }
.item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 0; border-bottom: 1px solid #eee; }
.nombre { flex: 1; }
.item input { width: 60px; }
.totales { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem; margin-top: 1rem; }
.totales dd { margin: 0; text-align: right; }
.vaciar { margin-top: 1rem; color: #dc2626; background: none; border: 0; cursor: pointer; }
</style>Store de autenticación
Este es el useAuthStore que usaste en los guards de Vue Router:
js
// src/stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// Simula la API de login. En la vida real: POST a tu backend (que pone una cookie httpOnly)
async function loginApi(email, password) {
await new Promise((r) => setTimeout(r, 600))
if (password !== '123456') throw new Error('Email o contraseña incorrectos')
return { id: 1, nombre: email.split('@')[0], email, rol: email.startsWith('admin') ? 'admin' : 'cliente' }
}
export const useAuthStore = defineStore('auth', () => {
const usuario = ref(JSON.parse(sessionStorage.getItem('usuario') ?? 'null'))
const cargando = ref(false)
const estaAutenticado = computed(() => Boolean(usuario.value))
const esAdmin = computed(() => usuario.value?.rol === 'admin')
async function login(email, password) {
cargando.value = true
try {
usuario.value = await loginApi(email, password)
sessionStorage.setItem('usuario', JSON.stringify(usuario.value))
} finally {
cargando.value = false
}
}
function logout() {
usuario.value = null
sessionStorage.removeItem('usuario')
}
return { usuario, cargando, estaAutenticado, esAdmin, login, logout }
})vue
<!-- Uso en el header -->
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
function salir() {
auth.logout()
router.push('/')
}
</script>
<template>
<div v-if="auth.estaAutenticado">
Hola, {{ auth.usuario.nombre }}
<RouterLink v-if="auth.esAdmin" to="/admin">Admin</RouterLink>
<button @click="salir">Salir</button>
</div>
<RouterLink v-else to="/login">Iniciar sesión</RouterLink>
</template>Seguridad
Guardar el usuario en el navegador sirve para la interfaz (mostrar su nombre, ocultar botones). Nunca guardes contraseñas ni confíes en el rol guardado en el navegador para dar acceso a datos: el backend debe verificar la sesión en cada petición. Para tokens, prefiere cookies httpOnly (Autenticación en Node).
Usar un store dentro de otro
js
// src/stores/pedidos.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useCarritoStore } from './carrito'
import { useAuthStore } from './auth'
export const usePedidosStore = defineStore('pedidos', () => {
const pedidos = ref([])
async function confirmarPedido(direccion) {
const carrito = useCarritoStore() // llama a otros stores dentro de las acciones
const auth = useAuthStore()
if (!auth.estaAutenticado) throw new Error('Inicia sesión para comprar')
const pedido = {
folio: `P-${Date.now().toString().slice(-6)}`,
cliente: auth.usuario.email,
items: structuredClone(carrito.items),
total: carrito.total,
direccion
}
// await fetch('/api/pedidos', { method: 'POST', body: JSON.stringify(pedido) })
pedidos.value.unshift(pedido)
carrito.vaciar()
return pedido
}
return { pedidos, confirmarPedido }
})Persistencia con plugin
En lugar de escribir el watch a mano en cada store, puedes usar pinia-plugin-persistedstate:
bash
npm i pinia-plugin-persistedstatejs
// src/main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// app.use(pinia)js
// En el store: tercer argumento con opciones
export const usePreferenciasStore = defineStore(
'preferencias',
() => {
const tema = ref('claro')
const idioma = ref('es')
return { tema, idioma }
},
{ persist: true } // se guarda en localStorage automáticamente
)Funciones útiles de un store
js
const carrito = useCarritoStore()
carrito.$reset() // reinicia el estado (sólo en stores con sintaxis options)
carrito.$patch({ items: [] }) // actualiza varias propiedades a la vez
carrito.$subscribe((mutacion, estado) => {
console.log('El carrito cambió', estado.items.length)
})
carrito.$onAction(({ name, args }) => {
console.log(`Acción ${name}`, args) // útil para analítica
})Buena práctica
- Un store por dominio:
auth,carrito,preferencias. No metas todo en uno solo. - Estado local de un componente (si un modal está abierto) va en
refdel componente, no en Pinia. - Abre la pestaña Pinia en Vue DevTools para ver y editar el estado en tiempo real.
Error común
Llamar a useAlgoStore() fuera de un componente o de una función que se ejecute después de app.use(pinia) (por ejemplo, al nivel superior de un archivo importado antes de crear la app). Llámalo dentro de setup, de acciones o de guards.
Ejercicios
- Crea
useFavoritosStoreque guarde ids de productos favoritos, conalternar(id),esFavorito(id)y un gettercantidad, persistido enlocalStorage. - Crea
useNotificacionesStorepara mostrar mensajes temporales (toasts):mostrar(texto, tipo)agrega una notificación que desaparece a los 3 segundos, y un componenteNotificaciones.vueque las muestre.
Ver solución
js
// 1. src/stores/favoritos.js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
export const useFavoritosStore = defineStore('favoritos', () => {
const ids = ref(JSON.parse(localStorage.getItem('favoritos') ?? '[]'))
const cantidad = computed(() => ids.value.length)
const esFavorito = (id) => ids.value.includes(id)
function alternar(id) {
ids.value = esFavorito(id) ? ids.value.filter((x) => x !== id) : [...ids.value, id]
}
watch(ids, (v) => localStorage.setItem('favoritos', JSON.stringify(v)))
return { ids, cantidad, esFavorito, alternar }
})js
// 2. src/stores/notificaciones.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useNotificacionesStore = defineStore('notificaciones', () => {
const lista = ref([])
function mostrar(texto, tipo = 'exito') {
const id = crypto.randomUUID()
lista.value.push({ id, texto, tipo })
setTimeout(() => cerrar(id), 3000)
}
function cerrar(id) {
lista.value = lista.value.filter((n) => n.id !== id)
}
return { lista, mostrar, cerrar }
})vue
<!-- src/components/Notificaciones.vue (agrégalo en App.vue) -->
<script setup>
import { useNotificacionesStore } from '@/stores/notificaciones'
const notificaciones = useNotificacionesStore()
</script>
<template>
<div class="contenedor" aria-live="polite">
<TransitionGroup name="toast">
<div v-for="n in notificaciones.lista" :key="n.id" class="toast" :class="n.tipo">
{{ n.texto }}
<button @click="notificaciones.cerrar(n.id)" aria-label="Cerrar">×</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.contenedor { position: fixed; bottom: 1rem; right: 1rem; display: grid; gap: 0.5rem; z-index: 50; }
.toast { display: flex; gap: 1rem; align-items: center; padding: 0.75rem 1rem; border-radius: 8px; color: white; background: #16a34a; }
.toast.error { background: #dc2626; }
.toast button { background: none; border: 0; color: inherit; font-size: 1.1rem; cursor: pointer; }
.toast-enter-active, .toast-leave-active { transition: all 0.3s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(30px); }
</style>Uso: useNotificacionesStore().mostrar('Producto agregado') desde cualquier componente o acción.
Resumen
defineStore(id, () => { ... })crea un store:ref= estado,computed= getters, funciones = acciones.- Úsalo en cualquier componente con
useXStore(); desestructura estado constoreToRefs. - Persiste con un
watchalocalStorageo conpinia-plugin-persistedstate. - Un store por dominio; el estado local de un componente se queda en el componente.