tutoriales.com

Gestión de Estado Centralizada con Pinia en Vue 3: Guía Completa

Este tutorial te guiará a través de la implementación de Pinia, la solución de gestión de estado recomendada para Vue 3, desde la instalación hasta la creación de stores complejos. Aprenderás a estructurar tu aplicación, acceder al estado y modificarlo de manera eficiente y escalable. Prepárate para dominar la gestión de estado en tus proyectos Vue.

Intermedio18 min de lectura8 views16 de marzo de 2026Reportar error

¡Hola, desarrollador! 👋 ¿Alguna vez te has encontrado con la necesidad de compartir datos entre componentes en Vue de una manera que se vuelve complicada a medida que tu aplicación crece? El prop drilling o el uso excesivo de eventos pueden ser un dolor de cabeza. Aquí es donde entra en juego la gestión de estado centralizada, y para Vue 3, la solución oficial y recomendada es Pinia.

Pinia es un almacén de estado intuitivo, ligero y tipado (si usas TypeScript) que te permite manejar el estado global de tu aplicación de una manera sencilla y eficiente. Es como Vuex, pero más simple y con una API más moderna.

En este tutorial exhaustivo, vamos a sumergirnos en Pinia, desde su instalación básica hasta técnicas avanzadas para que puedas dominar la gestión de estado en tus proyectos Vue 3.

🚀 ¿Qué es Pinia y por qué usarlo?

Pinia es una librería de gestión de estado para aplicaciones Vue, oficialmente recomendada por el equipo de Vue. Su objetivo principal es ofrecer una solución de gestión de estado más sencilla, ligera y con una mejor experiencia de desarrollo que su predecesor, Vuex 4.

🎯 Ventajas clave de Pinia:

  • Ligero y performante: Su tamaño es mínimo y su rendimiento es excelente.
  • Intuitivo: La API está diseñada para ser fácil de aprender y usar.
  • Soporte completo de TypeScript: Ofrece una inferencia de tipos sólida, lo que reduce errores y mejora la refactorización.
  • Modular y extensible: Permite organizar tu estado en módulos separados (stores) para una mejor escalabilidad.
  • Devtools amigables: Se integra perfectamente con las Vue Devtools, ofreciendo una experiencia de depuración excepcional.
  • No requiere mutations: Simplifica el flujo de datos al eliminar las mutaciones, utilizando actions para cambios de estado síncronos y asíncronos.
💡 Consejo: Si vienes de Vuex, encontrarás que Pinia es muy similar, pero con una sintaxis más limpia y menos "ceremonial". ¡Te encantará!

🛠️ Primeros pasos: Instalación y Configuración

Antes de sumergirnos en la creación de stores, necesitamos instalar Pinia en nuestro proyecto Vue 3. Si aún no tienes un proyecto Vue, puedes crearlo rápidamente con Vue CLI o Vite.

# Con npm
npm install pinia

# Con yarn
yarn add pinia

# Con pnpm
pnpm add pinia

Una vez instalado, debemos configurar Pinia en nuestra aplicación Vue. Esto se hace creando una instancia de Pinia y pasándola a nuestra aplicación Vue en el archivo main.js (o main.ts).

// src/main.js (o src/main.ts)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

¡Felicidades! 🎉 Pinia ya está integrado en tu aplicación y listo para ser utilizado.


📦 Creando tu primer Store con Pinia

Un store en Pinia es esencialmente un contenedor reactivo que guarda el estado de tu aplicación. Es una función que retorna un objeto con state, getters y actions. Vamos a crear un store simple para un contador.

📂 Estructura de archivos recomendada

Es una buena práctica organizar tus stores en una carpeta separada, por ejemplo, src/stores.

src/
├── assets/
├── components/
├── stores/
│   └── counter.js (o counter.ts)
└── App.vue
└── main.js

✍️ Definiendo el Store counter

Crea un archivo src/stores/counter.js con el siguiente contenido:

// src/stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // `id` es un string único para identificar el store
  // En este caso, 'counter'

  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  getters: {
    doubleCount: (state) => state.count * 2,
    greeting: (state) => `Hola, ${state.name}! Tu cuenta es ${state.count}.`
  },

  actions: {
    increment() {
      this.count++
    },
    incrementBy(amount) {
      this.count += amount
    },
    // Las acciones también pueden ser asíncronas
    async decrementLater() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count--
    }
  }
})

Analicemos las partes de un store:

  1. defineStore('counter', { ... }): Esta función de Pinia define un nuevo store. El primer argumento es un ID único para el store ('counter'). Es importante que este ID sea único globalmente en tu aplicación.
  2. state: Es una función que retorna el estado inicial del store. Aquí es donde declaras todas las variables reactivas que quieres compartir. Similar al data de un componente Vue.
  3. getters: Son funciones computadas para tu store. Reciben el estado como primer argumento y pueden acceder a otros getters (this). Son útiles para derivar nuevos estados o filtrar el estado existente sin modificarlo directamente. Actúan como las computed properties para tu store.
  4. actions: Son funciones que pueden modificar el estado. Pueden ser síncronas o asíncronas. Son el lugar ideal para tu lógica de negocio, llamadas a APIs, etc. Similar a los methods de un componente Vue.
🔥 Importante: Dentro de `getters` y `actions`, puedes acceder a `this` para referenciar el store completo (incluyendo `state`, otros `getters` y `actions`).

⚛️ Usando el Store en tus Componentes

Ahora que hemos definido nuestro counter store, vamos a ver cómo podemos interactuar con él desde cualquier componente Vue.

📝 Accediendo al estado y getters

Para acceder a un store en un componente, primero debes importarlo y luego "usarlo" llamando a la función que retorna defineStore.

<!-- src/components/CounterDisplay.vue -->
<script setup>
import { useCounterStore } from '../stores/counter'

const counter = useCounterStore()

// Acceder directamente al estado (es reactivo)
console.log(counter.count)

// Acceder a un getter
console.log(counter.doubleCount)

// Puedes desestructurar el estado, pero perderás la reactividad
// Para mantener la reactividad, usa storeToRefs
import { storeToRefs } from 'pinia'
const { count, name } = storeToRefs(counter)

</script>

<template>
  <div>
    <h2>Contador:</h2>
    <p>La cuenta actual es: {{ counter.count }}</p>
    <p>El doble de la cuenta es: {{ counter.doubleCount }}</p>
    <p>{{ counter.greeting }}</p>
    
    <!-- Usando las propiedades desestructuradas reactivas -->
    <p>Cuenta desde storeToRefs: {{ count }}</p>
    <p>Nombre desde storeToRefs: {{ name }}</p>
  </div>
</template>

Explicación:

  • useCounterStore(): Al llamar a esta función, obtienes una instancia del store. Es importante que la llames dentro de setup() o script setup para que Pinia pueda inyectar el store correctamente en el contexto de la aplicación.
  • Acceso directo (counter.count): Puedes acceder a las propiedades del estado y a los getters directamente desde la instancia del store. Estas propiedades son reactivas.
  • storeToRefs(): Si necesitas desestructurar propiedades del estado (por ejemplo, count y name) para usarlas directamente en tu template o como referencias reactivas, storeToRefs es tu mejor amigo. Esto convierte las propiedades reactivas del store en refs, asegurando que mantengan su reactividad.
⚠️ Advertencia: Desestructurar directamente `const { count } = counter` *sin* `storeToRefs` hará que `count` pierda su reactividad. Siempre usa `storeToRefs` para desestructurar propiedades del estado si necesitas que sigan siendo reactivas.

🔄 Modificando el estado con Actions

Para cambiar el estado de tu store, llamas a las acciones definidas en él. Esto garantiza un flujo de datos predecible y depurable.

<!-- src/components/CounterControls.vue -->
<script setup>
import { useCounterStore } from '../stores/counter'

const counter = useCounterStore()

const handleIncrement = () => {
  counter.increment()
}

const handleIncrementByFive = () => {
  counter.incrementBy(5)
}

const handleDecrementLater = async () => {
  await counter.decrementLater()
  console.log('Decremento asíncrono completado.')
}
</script>

<template>
  <div>
    <button @click="handleIncrement">Incrementar</button>
    <button @click="handleIncrementByFive">Incrementar en 5</button>
    <button @click="handleDecrementLater">Decrementar después de 1s</button>
  </div>
</template>

Las acciones son la forma recomendada de modificar el estado. Encapsulan la lógica de negocio y hacen que los cambios de estado sean más predecibles.

✏️ Modificando el estado directamente (parcialmente)

Aunque se recomienda usar acciones para la mayoría de los cambios, Pinia también permite modificar el estado directamente si lo deseas, especialmente para cambios pequeños. Puedes hacer esto asignando un nuevo valor a una propiedad del estado o utilizando el método $patch.

<!-- src/components/CounterDirectEdit.vue -->
<script setup>
import { useCounterStore } from '../stores/counter'

const counter = useCounterStore()

const changeNameDirectly = () => {
  counter.name = 'Juan'
}

const resetCounterPatch = () => {
  // Usando $patch con un objeto
  counter.$patch({
    count: 0,
    name: 'Usuario Restablecido'
  })
}

const incrementWithPatchFunction = () => {
  // Usando $patch con una función
  counter.$patch((state) => {
    state.count++
    state.name = state.name.toUpperCase()
  })
}
</script>

<template>
  <div>
    <h3>Edición Directa y con $patch:</h3>
    <p>Nombre actual: {{ counter.name }}</p>
    <button @click="changeNameDirectly">Cambiar nombre a Juan</button>
    <button @click="resetCounterPatch">Resetear y cambiar nombre con $patch</button>
    <button @click="incrementWithPatchFunction">Incrementar y Mayúsculas con $patch (función)</button>
  </div>
</template>

📌 Nota: `$patch` es una forma eficiente de realizar múltiples cambios de estado a la vez, ya sea pasando un objeto con las propiedades a cambiar o una función que recibe el estado y lo modifica. Es más performante que hacer múltiples asignaciones individuales.

🧩 Múltiples Stores y Módulos

A medida que tu aplicación crece, es probable que quieras dividir tu estado en múltiples stores, cada uno manejando una parte lógica de tu aplicación (por ejemplo, userStore, productsStore, cartStore). Esto promueve una mejor organización y modularidad.

Ejemplo: Un Store de Autenticación

Vamos a crear un nuevo store para manejar el estado de autenticación del usuario.

src/stores/auth.js

// src/stores/auth.js
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    isAuthenticated: false,
    user: null
  }),
  getters: {
    isLoggedIn: (state) => state.isAuthenticated,
    userDisplayName: (state) => state.user ? state.user.name : 'Invitado'
  },
  actions: {
    login(userData) {
      this.isAuthenticated = true
      this.user = { ...userData, name: userData.email.split('@')[0] }
      // Simular guardado en localStorage
      localStorage.setItem('userToken', 'fake-jwt-token')
    },
    logout() {
      this.isAuthenticated = false
      this.user = null
      localStorage.removeItem('userToken')
    },
    // Acción para inicializar el estado al cargar la app
    initializeAuth() {
      if (localStorage.getItem('userToken')) {
        // En una app real, aquí se validaría el token con el backend
        this.isAuthenticated = true
        this.user = { name: 'Usuario Token', email: 'token@example.com' }
      }
    }
  }
})

Ahora puedes usar este store en cualquier componente, de forma independiente del counterStore.

<!-- src/components/AuthStatus.vue -->
<script setup>
import { useAuthStore } from '../stores/auth'
import { onMounted } from 'vue'

const auth = useAuthStore()

onMounted(() => {
  // Inicializar estado de autenticación al montar el componente
  auth.initializeAuth()
})

const performLogin = () => {
  auth.login({ email: 'test@example.com' })
}
</script>

<template>
  <div>
    <h3>Estado de Autenticación:</h3>
    <p>Usuario: <strong>{{ auth.userDisplayName }}</strong></p>
    <p>Autenticado: <span :class="auth.isLoggedIn ? 'badge green' : 'badge red'">{{ auth.isLoggedIn ? 'Sí' : 'No' }}</span></p>
    
    <div v-if="!auth.isLoggedIn">
      <button @click="performLogin">Iniciar Sesión</button>
    </div>
    <div v-else>
      <button @click="auth.logout">Cerrar Sesión</button>
    </div>
  </div>
</template>

🌐 Interacción entre Stores

Es común que necesites interactuar entre diferentes stores. Pinia facilita esto. Simplemente importa y usa el store deseado dentro de otro store.

Ejemplo: Reiniciar contador al cerrar sesión

Supongamos que queremos que el counterStore se reinicie a 0 cada vez que el usuario cierra sesión desde el authStore.

Modificamos src/stores/auth.js:

// src/stores/auth.js (modificado)
import { defineStore } from 'pinia'
import { useCounterStore } from './counter' // Importamos el counterStore

export const useAuthStore = defineStore('auth', {
  state: () => ({
    isAuthenticated: false,
    user: null
  }),
  getters: {
    isLoggedIn: (state) => state.isAuthenticated,
    userDisplayName: (state) => state.user ? state.user.name : 'Invitado'
  },
  actions: {
    login(userData) {
      this.isAuthenticated = true
      this.user = { ...userData, name: userData.email.split('@')[0] }
      localStorage.setItem('userToken', 'fake-jwt-token')
    },
    logout() {
      this.isAuthenticated = false
      this.user = null
      localStorage.removeItem('userToken')

      // INTERACCIÓN ENTRE STORES: Reiniciar el contador al cerrar sesión
      const counterStore = useCounterStore()
      counterStore.$reset() // Pinia proporciona un método $reset para restaurar el estado inicial
      // O también:
      // counterStore.count = 0
      // counterStore.name = 'Eduardo'
    },
    initializeAuth() {
      if (localStorage.getItem('userToken')) {
        this.isAuthenticated = true
        this.user = { name: 'Usuario Token', email: 'token@example.com' }
      }
    }
  }
})
💡 Consejo: Pinia automáticamente detecta si `useCounterStore()` ya ha sido instanciado y te devuelve la misma instancia singleton, lo cual es muy eficiente.

🔍 Patrones Avanzados con Pinia

Pinia es muy flexible y permite implementar patrones más complejos para satisfacer las necesidades de aplicaciones grandes.

Suscripciones a cambios de estado

Puedes suscribirte a los cambios de estado de un store usando el método $subscribe. Esto es útil para efectos secundarios, como guardar el estado en localStorage o realizar alguna acción cuando una parte específica del estado cambia.

// src/stores/counter.js (modificado con $subscribe)
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  getters: {
    doubleCount: (state) => state.count * 2,
    greeting: (state) => `Hola, ${state.name}! Tu cuenta es ${state.count}.`
  },

  actions: {
    increment() {
      this.count++
    },
    incrementBy(amount) {
      this.count += amount
    },
    async decrementLater() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count--
    }
  }
})

// Suscribirse a los cambios del store (fuera de la definición, o en un componente)
// Esto podría estar en main.js o en un plugin de Pinia

// Ejemplo en un componente (para ver cómo funcionaría)
// <script setup>
// import { useCounterStore } from '../stores/counter'
// import { onMounted, onUnmounted } from 'vue'

// const counter = useCounterStore()

// let unsubscribe
// onMounted(() => {
//   unsubscribe = counter.$subscribe((mutation, state) => {
//     console.log('Cambio en el contador:', state.count)
//     // Guardar el estado en localStorage, por ejemplo
//     localStorage.setItem('counterState', JSON.stringify(state))
//   })
// })

// onUnmounted(() => {
//   // Importante desuscribirse para evitar fugas de memoria
//   unsubscribe()
// })
// </script>

El método $subscribe toma un callback que recibe dos argumentos: mutation (información sobre el cambio que ocurrió) y state (el estado actual del store).

Plugins de Pinia

Los plugins de Pinia son una forma poderosa de extender la funcionalidad de los stores, como añadir propiedades personalizadas, inyectar nuevas opciones, o incluso persistir el estado automáticamente. Un plugin es una función que recibe el contexto de Pinia y el store actual.

// src/plugins/pinia-logger.js
export function piniaLogger({ store }) {
  store.$onAction(({ name, storeId, args, after, onError }) => {
    const startTime = Date.now()
    console.log(`🚀 Acción '${name}' del store '${storeId}' comenzando con args:`, args)

    after(() => {
      console.log(`✅ Acción '${name}' del store '${storeId}' completada en ${Date.now() - startTime}ms. Nuevo estado:`, store.$state)
    })

    onError((error) => {
      console.error(`❌ Acción '${name}' del store '${storeId}' falló con error:`, error)
    })
  })
}

// src/main.js (cómo usar el plugin)
// import { createPinia } from 'pinia'
// import { piniaLogger } from './plugins/pinia-logger'

// const pinia = createPinia()
// pinia.use(piniaLogger)
// app.use(pinia)

Este plugin de ejemplo simplemente registra las acciones en la consola, incluyendo el tiempo que tardan y si fallaron o no. Puedes imaginar plugins para persistencia (como pinia-plugin-persistedstate), gestión de errores global, etc.

🔥 Importante: Los plugins son una característica avanzada y muy potente para añadir funcionalidad global a tus stores sin modificar cada uno individualmente.

Diagrama de Flujo de Datos en Pinia

Despachan Modifican Actualiza Consumen Observan Componentes Acciones Estado Getters PINIA FLUJO DE DATOS

Patrón Composable con Pinia (Composable Stores)

En Vue 3, los Composable Functions son una forma poderosa de reutilizar lógica con estado. Pinia se integra perfectamente con este concepto, ya que los stores mismos pueden considerarse "composables". Puedes incluso crear composables personalizados que utilicen stores Pinia para encapsular lógica más compleja.

// src/composables/useUserProfile.js
import { computed } from 'vue'
import { useAuthStore } from '../stores/auth'

export function useUserProfile() {
  const authStore = useAuthStore()

  const userName = computed(() => authStore.userDisplayName)
  const userEmail = computed(() => authStore.user ? authStore.user.email : 'N/A')
  const isLogged = computed(() => authStore.isLoggedIn)

  const loginUser = (email, password) => {
    // Lógica más compleja para login, quizás llamando a una API
    // Y luego actualizando el store
    console.log(`Intentando logear con ${email}...`)
    authStore.login({ email: email, name: email.split('@')[0] })
  }

  const logoutUser = () => {
    authStore.logout()
  }

  return {
    userName,
    userEmail,
    isLogged,
    loginUser,
    logoutUser
  }
}

Luego, en un componente:

<!-- src/components/UserProfile.vue -->
<script setup>
import { useUserProfile } from '../composables/useUserProfile'

const { userName, userEmail, isLogged, loginUser, logoutUser } = useUserProfile()

const handleLogin = () => {
  loginUser('myuser@example.com', 'password123')
}
</script>

<template>
  <div>
    <h3>Perfil de Usuario (via Composable):</h3>
    <p>Nombre: {{ userName }}</p>
    <p>Email: {{ userEmail }}</p>
    <p>Estado: <span :class="isLogged ? 'badge green' : 'badge red'">{{ isLogged ? 'Conectado' : 'Desconectado' }}</span></p>
    <button v-if="!isLogged" @click="handleLogin">Login</button>
    <button v-else @click="logoutUser">Logout</button>
  </div>
</template>

Este patrón te permite agrupar lógica relacionada con el usuario y su autenticación, ofreciendo una API limpia a tus componentes.


💡 Consejos y Buenas Prácticas

  • Modulariza tus stores: Divide tu estado en stores pequeños y específicos por dominio (e.g., user, cart, products).
  • Usa storeToRefs para desestructurar: Siempre que desestructuras propiedades de estado reactivo, utiliza storeToRefs para mantener la reactividad.
  • Prefiere acciones para modificar el estado: Encapsula la lógica de negocio y las mutaciones complejas dentro de las acciones para mantener un flujo de datos predecible.
  • Nombra tus stores de forma clara: Utiliza nombres descriptivos y consistentes para tus archivos de store y los IDs de defineStore.
  • Aprovecha TypeScript: Si usas TypeScript, Pinia ofrece una excelente inferencia de tipos, lo que te ayudará a detectar errores en tiempo de desarrollo.
  • Devtools: Utiliza las Vue Devtools (sección Pinia) para depurar tu estado, ver cambios y observar acciones. Es una herramienta invaluable.
⚠️ Advertencia: Evita la lógica de negocio compleja directamente en los getters. Los getters deben ser funciones puras que solo deriven o filtren el estado. La lógica con efectos secundarios o asíncrona va en las acciones.

Tabla Comparativa: Pinia vs. Vuex (en Vue 3)

CaracterísticaPiniaVuex 4
SintaxisMás simple y orientada a opcionesMás "ceremonial" (modules, state, getters, mutations, actions)
mutationsNo existen (acciones manejan todo)Obligatorias para cambios de estado síncronos
TypeScriptSoporte nativo y robusto, gran inferenciaRequiere más configuración y Type Naming
DevtoolsIntegración excelenteBuena integración
Tamaño de BundleMás pequeñoMás grande
ModularidadMódulos simples, sin anidamientoSoporta módulos anidados
InstalacióncreatePinia().use(...)createStore().use(...)
Recomendado para Vue 3Sí, oficial y preferidoAún soportado, pero Pinia es el futuro

🏁 Conclusión

Pinia se ha establecido como la solución de gestión de estado de facto para las aplicaciones Vue 3. Su API simplificada, excelente soporte para TypeScript y su integración perfecta con las Vue Devtools la convierten en una herramienta imprescindible para cualquier desarrollador de Vue.

Al dominar Pinia, no solo mejorarás la organización y mantenibilidad de tu código, sino que también facilitarás la colaboración en equipo y harás que tus aplicaciones sean más robustas y escalables. ¡Espero que este tutorial te haya proporcionado una base sólida para empezar a usar Pinia en tus próximos proyectos!

¡Feliz codificación! 🚀

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!