Tutorial de introducción a Pinia

En Vue3, la gestión del estado es un tema inevitable.

Pinia es una biblioteca ligera de gestión de estado para Vue.js, que te permite compartir y gestionar estado entre componentes. Podemos imaginar Pinia como un almacén de datos global, donde todos los componentes pueden obtener o actualizar datos.

En este capítulo presentaremos la biblioteca de gestión de estado recomendada oficialmente para Vue3: Pinia. En comparación con Vuex, Pinia ofrece un esquema de gestión de estado más simple y más acorde con la filosofía de la Composition API de Vue3.

La siguiente imagen compara las diferencias estructurales entre ambos:

  • Vuex utiliza una estructura de árbol con un solo store y módulos de varios niveles, jerarquía fija, depende de mutations, y es más pesado en general.
  • Pinia, en cambio, se compone de múltiples stores independientes: es plano, ligero, sin división en módulos y sin la carga de los espacios de nombres.

Características principales de Pinia:

  • Compatible con Vue2 y Vue3
  • Diseño de API minimalista
  • Soporte completo de TypeScript
  • Compatible con Composition API
  • Diseño modular, sin necesidad de módulos anidados

La estructura plana de Pinia permite que los componentes se conecten directamente a cualquier store y tomen lo que necesiten, sin pasar por módulos en árbol ni por rutas de espacios de nombres. El flujo de estado es simple y directo, con bajo acoplamiento.


Instalación y configuración

Crear un proyecto con Vite:

npm create vite@latest vue-pinia-demo --template vue
cd vue-pinia-demo
npm install

Instalar Pinia

Primero, instala Pinia en tu proyecto Vue3:

npm install pinia
# 或者
yarn add pinia

Pinia tiene tres partes:

  • state: almacena datos
  • getters: calcula datos derivados
  • actions: ejecutan lógica y modifican el state

Configurar Pinia

Configura Pinia en src/main.js o src/main.ts:

Ejemplo

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

// Crear la instancia de Pinia
const pinia = createPinia()
// Crear la aplicación Vue
const app = createApp(App)

// Usar Pinia
app.use(pinia)
app.mount('#app')

Crear el primer Store

Un Store es el almacén de datos en Pinia. Vamos a crear un Store de contador simple.

Definir el Store

Crear un nuevo archivo src/stores/useCounter.js:

Ejemplo

// stores/useCounter.js
import { defineStore } from 'pinia'

// Usar defineStore para definir el store
// El primer parámetro es el ID único del store
// El segundo parámetro son las opciones de configuración del store
export const useCounterStore = defineStore('counter', {
  // state: define los datos de estado del store
  state: () => ({
    count: 0,
    name: 'Mi contador'
  }),

  // getters: define propiedades calculadas basadas en state
  getters: {
    doubleCount: (state) => state.count * 2,
    // Usar this para acceder a otros getters
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // actions: define métodos para modificar state
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    // Puede recibir parámetros
    incrementBy(amount) {
      this.count += amount
    },
    // action asíncrono
    async incrementAsync() {
      // Simular una operación asíncrona
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})

Explicación de la estructura del Store

Entendamos las partes del Store mediante una tabla:

Parte Función Ejemplo
state Define los datos almacenados count: 0
getters Propiedades calculadas basadas en state doubleCount: state => state.count * 2
actions Métodos que modifican el state increment() { this.count++ }

Usar el Store en un componente

Uso básico

El código del componente src/components/CounterComponent.vue es el siguiente:

Ejemplo

<!-- CounterComponent.vue -->
<template>
  <div class="counter">
    <h3>{{ store.name }}</h3>
    <p>Contador actual: {{ store.count }}</p>
    <p>Doble contador: {{ store.doubleCount }}</p>
    <p>Doble más uno: {{ store.doubleCountPlusOne }}</p>
   
    <button @click="store.increment()">+1</button>
    <button @click="store.decrement()">-1</button>
    <button @click="store.incrementBy(5)">+5</button>
    <button @click="store.incrementAsync()">Asíncrono +1</button>
   
    <button @click="reset">Restablecer</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '../stores/useCounter'

// En el setup usar el store
const store = useCounterStore()

// Método para restablecer el estado
function reset() {
store.$reset() // $reset puede restablecer el state a su valor inicial
}
</script>

Modifica el código de src/App.vue de la siguiente manera:

Ejemplo

<script setup>
import CounterComponent from './components/CounterComponent.vue'
</script>

<template>
  <CounterComponent />
</template>

Estructura completa del proyecto:

Ejecutanpm run devel comando, y accede en el navegador ahttp://localhost:5173/, y observa el resultado:

Desestructuración reactiva

Si quieres usar directamente las propiedades de state en la plantilla, puedes usarstoreToRefs:

Ejemplo

<template>
  <div>
    <p>Contador: {{ count }}</p>
    <p>Nombre: {{ name }}</p>
  </div>
</template>

<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()

// Usar storeToRefs para mantener la reactividad
const { count, name } = storeToRefs(store)

// Nota: ¡desestructurar directamente perderá la reactividad!
// Forma incorrecta: const { count, name } = store
</script>

Store al estilo Composition API

Pinia también admite definir Stores con el estilo de la Composition API:

Ejemplo

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

export const useUserStore = defineStore('user', () => {
  // state
  const user = ref(null)
  const isLoggedIn = ref(false)

  // getters
  const userName = computed(() => user.value?.name || 'Visitante')
  const userAge = computed(() => user.value?.age || 0)

  // actions
  function login(userData) {
    user.value = userData
    isLoggedIn.value = true
  }

  function logout() {
    user.value = null
    isLoggedIn.value = false
  }

  return {
    user,
    isLoggedIn,
    userName,
    userAge,
    login,
    logout
  }
})

Interacción entre Stores

Varios Stores pueden llamarse entre sí:

Ejemplo

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),

  actions: {
    addItem(product) {
      this.items.push(product)
     
      // Llamar a la action de otro store
      const userStore = useUserStore()
      if (userStore.isLoggedIn) {
        // Sincronizar con el carrito del usuario
        this.syncToUserCart()
      }
    }
  }
})

Ejemplo práctico: carrito de compras

Creemos un ejemplo completo de carrito de compras:

Ejemplo

// stores/products.js
export const useProductStore = defineStore('products', {
  state: () => ({
    products: [
      { id: 1, name: 'Computadora portátil', price: 5999, stock: 10 },
      { id: 2, name: 'Teléfono inteligente', price: 3999, stock: 20 },
      { id: 3, name: 'Auriculares inalámbricos', price: 299, stock: 50 }
    ]
  }),

  getters: {
    // Obtener el producto según su ID
    getProductById: (state) => (id) => {
      return state.products.find(product => product.id === id)
    },
    // Obtener los productos en stock
    availableProducts: (state) => {
      return state.products.filter(product => product.stock > 0)
    }
  }
})

Ejemplo

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [], // { productId, quantity }
    discount: 0
  }),

  getters: {
    // Número total de artículos en el carrito
    totalItems: (state) => {
      return state.items.reduce((total, item) => total + item.quantity, 0)
    },
    // Importe total del carrito
    totalPrice: (state) => {
      const productStore = useProductStore()
      return state.items.reduce((total, item) => {
        const product = productStore.getProductById(item.productId)
        return total + (product?.price || 0) * item.quantity
      }, 0)
    },
    // Precio total después del descuento
    finalPrice: (state) => {
      return state.totalPrice * (1 - state.discount / 100)
    }
  },

  actions: {
    // Agregar un producto al carrito
    addToCart(productId, quantity = 1) {
      const existingItem = this.items.find(item => item.productId === productId)
     
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({ productId, quantity })
      }
    },

    // Eliminar un producto del carrito
    removeFromCart(productId) {
      this.items = this.items.filter(item => item.productId !== productId)
    },

    // Vaciar el carrito
    clearCart() {
      this.items = []
      this.discount = 0
    },

    // Establecer el descuento
    setDiscount(percent) {
      this.discount = Math.max(0, Math.min(100, percent))
    }
  }
})

Usar el carrito de compras en un componente

Ejemplo

<!-- ShoppingCart.vue -->
<template>
  <div class="shopping-cart">
    <h3>Carrito ({{ cart.totalItems }} artículos)</h3>
   
    <div v-if="cart.items.length === 0">
      <p>El carrito está vacío</p>
    </div>
   
    <div v-else>
      <div v-for="item in cartItems" :key="item.product.id" class="cart-item">
        <span>{{ item.product.name }}</span>
        <span>¥{{ item.product.price }}</span>
        <span>Cantidad: {{ item.quantity }}</span>
        <span>Subtotal: ¥{{ item.product.price * item.quantity }}</span>
        <button @click="cart.removeFromCart(item.product.id)">Eliminar</button>
      </div>
     
      <div class="cart-summary">
        <p>Total: ¥{{ cart.totalPrice }}</p>
        <p v-if="cart.discount >0">Descuento: {{ cart.discount }}%</p>
        <p>Total a pagar: ¥{{ cart.finalPrice }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useProductStore } from '@/stores/products'

const cart = useCartStore()
const products = useProductStore()

// Calcular los detalles de los productos del carrito
const cartItems = computed(() => {
  return cart.items.map(item => ({
    product: products.getProductById(item.productId),
    quantity: item.quantity
} }).filter(item => item.product) // Filtrar los productos que no existen
})
</script>

Mejores prácticas y consideraciones

1. Convenciones de nomenclatura del Store

Ejemplo

// Buenos nombres
export const useUserStore = defineStore('user', { /* ... */ })
export const useProductStore = defineStore('products', { /* ... */ })

// Nombres a evitar
export const userStore = defineStore('user', { /* ... */ }) // Falta el prefijo use

2. Inicialización del estado

Ejemplo

// Recomendado: usar una función para devolver el estado inicial
state: () => ({
  items: [],
  loading: false,
  error: null
})

// No recomendado: usar un objeto directamente
state: {
  items: []  // ¡Esto hará que todas las instancias compartan el mismo array!
}

3. Manejo de operaciones asíncronas

Ejemplo

actions: {
  async fetchUserData(userId) {
    this.loading = true
    this.error = null
   
    try {
      const response = await api.getUser(userId)
      this.user = response.data
    } catch (error) {
      this.error = error.message
    } finally {
      this.loading = false
    }
  }
}

4. Persistencia de datos

Para los datos que necesitan persistencia, se puede usar un plugin:

npm install pinia-plugin-persistedstate

Ejemplo

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: {}
  }),
 
  // Habilitar persistencia
  persist: true
})

Pinia API

Categoría API Descripción
Creación defineStore Definir un store independiente que contenga state / getters / actions
Instanciación createPinia Crear la instancia raíz de Pinia, para usarla en app.use()
Uso en componentes useStore()(definido por el usuario, comouseUserStore) Llamar a un store, devuelve state, getters y actions reactivos
Estado state Devuelve una función-objeto para definir el estado global mutable
Operaciones de estado store.$state Leer y escribir directamente todo el state (a nivel de objeto)
Operaciones de estado store.$patch() Modificar state en lote, admite dos modos: objeto y función
Reemplazo de estado store.$reset() Restablecer al state inicial, solo disponible cuando se define con el método setup
Suscripción de estado store.$subscribe() Escuchar cambios en state, adecuado para persistencia local
Llamada de actions store.$onAction() Escuchar antes y después de la llamada a actions, se puede usar para registros y seguimiento
Getter getters Datos derivados, caché automático basado en state
Plugins pinia.use() Registrar plugins, ampliar las capacidades del store
Método de definición defineStore(id, options) Sintaxis de Options API
Método de definición defineStore(id, () => {...}) Sintaxis de Setup, devuelve state / getter / action
Propiedad del store store.$id Identificador único del store actual
Propiedad del store store.$ready(en algunas versiones) Indicador de estado después de que el store se inicializa
Persistencia (plugin) persist Habilitar plugin de almacenamiento (como localStorage)
Funciones de utilidad storeToRefs() Convertir state / getter en refs, manteniendo la reactividad

Para más contenido, consulte el sitio oficial de Pinia:https://pinia.vuejs.org/

Otras extensiones