Skip to content
En esta página

Comunicación entre componentes no emparentados

Como vimos en el post anterior, pasar data entre componentes que no tienen relación directa es bastante complicado. Hay que crear cadenas de props y emits para llevar la información de un componente a otro que se pueden romper en el camino.

Y además hay que centralizar toda la lógica de la aplicación en el componente ancestro (en este caso, App.vue) que es a donde llega y de donde salen esas cadenas de props y emits:

js
import NavBar from './components/NavBar.vue'
import ErrorMessage from './components/ErrorMessage.vue'
import ProductCard from './components/ProductCard.vue'

export default {

  name: 'App',

  components: {
    NavBar, ErrorMessage, ProductCard
  },

  data: () => ({
    cart: []
  }),

  methods: {
    addToCart(product) {
      this.cart.push(product)
    }
  }
}

Si usamos cadenas de props y emits el método que agrega un producto al carrito tiene que estar sí o sí en App.vue porque de ahí sale y ahí llega toda la información. Si los métodos que cambian el valor de cart estuviesen en distintos componentes podría ocurrir que los distintos valores de cart se pisen entre sí y eso sería un caos 😕️

El problema de tener toda la lógica de la aplicación concentrada en App.vue es que a medida que la aplicación crece este componente va a ir creciendo en tamaño hasta volverse inmanejable por la cantidad y complejidad de los methods que contendría.

Para evitar los problemas en la comunicación entre componentes sin relación directa Vue ofrece distintas soluciones:

(TL;DR: La mejor solución, si aún no están usando Vuex o Pinia, es usando una store).

Provide-Inject

Usando provide e inject podemos pasar props de un componente ancestro a un componente descendiente (bisnieto, digamos) salteándonos los componentes que hay en el medio.

En nuestro caso sería así:

Con Prop Drilling:

App.vue -> NavBar -> CartCounter -> CartVue

Con Provide-Inject:

App.vue -> CartVue

Parece fácil, pero la forma de implementarlo es bastante enroscada... 😐️

Primero hay que crear un observable en un archivo .js (no .vue):

js
// src/observables/cartObservable.js

import Vue from 'vue'

export const cart = Vue.observable({ value: [] })

Luego importar el observable en el componente ancestro (App.vue) y declararlo dentro del objeto provide y de data:

js
import { cart } from './observables/counterObservable'

export default {

  components: {
    NavBar, ErrorMessage, ProductCard
  },

  // provide le 'provee' el valor de cart a todos los componentes descendientes
  provide: () => ({
    cart
  }),

  // Para que cart pueda ser modificado debe estar declarado nuevamente en data
  data: () => ({
    cart
  }),

  methods: {
    addToCart(product) {
      // Debe usarse la propiedad 'value'
      this.cart.value.push(product)
    }
  }
}

Y luego 'inyectar' el array cart en CartVue:

js
export default {
  
  inject: ['cart'],

  data: // etc...
}

Y todo esto sólo para saltearse un par de componentes en la cadena de props... 😒️

Además, esto no soluciona el problema de tener toda la lógica concentrada en el componente ancestro.

Event Bus

El event bus sirve para crear un emit en algún componente que pueda ser recibido por cualquier otro componente, y no únicamente en el componente padre.

Primero, dentro de un archivo .js (no .vue) hay que crear una instancia de Vue:

js
// src/eventbus/index.js

import Vue from 'vue'

export const eventBus = new Vue()

Luego, desde cualquier componente, crear un emit:

js
// src/copmonents/ComponenteUno.vue

import { eventBus } from '@/eventbus'

export default {

  data: () => ({
    someData: 'data emitida'
  }),

  methods: {
    emitEventBus() {
      eventBus.$emit('emit-data', this.someData)
    },
  },
}

Y en cualquier otro componente recibir el emit:

js
// src/copmonents/ComponenteDos.vue

import { eventBus } from '@/eventbus'

export default {

  created() {
    // El segundo param debe ser un callback
    eventBus.$on('emit-data', data => this.someData = data)
  },

  data: () => ({
    someData: ''
  })
}

Funciona, pero es unidireccional, con lo cual, sólo sirve para enviar información de un componente a otro, no para compartir el estado entre componentes, que es lo que necesitamos si queremos, por ejemplo, compartir el estado de un carrito de compras en un e-commerce.

Mixins

Los mixins funcionan como componentes puramente lógicos (sin template) que pueden ser declarados en un archivo .js (no .vue) y luego ser importados en un componente normal. De esta forma tanto la data como los methods, computed y cualquier otra propiedad del mixin pueden ser reutilizados en cualquier componente que los necesite.

Esto permite poder reutilizar lógica y compartir estado entre distintos componentes de la aplicación.

js
// src/mixins/cartMixin.js

export const cartMixin = {

  data: {   
    cart: []
  },

  methods: {
    addToCart(product) {
      this.cart.push({
        ...product,
        qty: 1, 
        subtotal: product.price
      })    
    },

    findProductById(id) {
      return this.cart.find(product => product.id === id)
    }
  }
}

Hay que tener en cuenta que para que un mixin pueda compartir estado el objeto data debe ser declarado como un objeto normal, o sea:

js
data: {
  cart: []
}

Y no en la forma usual, o sea, como una factory function que retorna un objeto:

js
data() {
  return {
    cart: []
  }
}

// O con una arrow function:
data: () => ({
  cart: []
})

Esto es porque las factory functions actúan en forma similar a las classes, es decir, generan distintas instancias de objetos. En el caso de Vue esto es muy útil ya que permite que cada componente tenga su propio estado, de lo contrario todos los componentes tendrían un estado global y la aplicación sería un caos 😬️

Pero como lo que queremos es comunicar un mismo estado entre distintos componentes, en este caso sí es mejor usar un objeto.

Entonces, una vez creado el mixin éste puede ser importado en algún componente y declarado dentro de un array de mixins:

html
<template>

  <button 
    @click="addToCart(product)"
    :class="colorFondo"
  > 
    {{ inCart ? 'Agregado' : 'Agregar al carrito' }} 
  </button>

</template>

<script>

import { cartMixin } from '@/mixins/cartMixin'

export default {

  mixins: [ cartMixin ],

  props: {
    product: {
      id: String,
      name: String,
      price: Number
      image: String
    }
  },

  computed: {
    inCart() {
      return this.findProductById(this.product.id)
    },
    colorFondo() {
      return this.inCart ? 'fondo-verde' : 'fondo-azul'
    }
  }
}
</script>

El problema con esto es que se presta a mucha confusión porque los métodos del mixin aparecen en el componentes como de la nada, sin que sean importados expresamente.

Al ver el código del componente es difícil entender de dónde salieron this.findProductById o addToCart si nunca fueron declarados 🤔️

Y a medida que la aplicación crezca el mixin va a tener cada vez más métodos, con lo cual pueden ocurrir colisiones de nombres con métodos que podamos haber creado dentro de los componentes.

En definitiva, los mixins son tan problemáticos que hasta en la documentación oficial de Vue recomiendan dejar de usarlos 🙄️

Vuex

Esta solución es claramente la mejor pero tiene un problema: su implementación es bastante compleja. La explicación es larga, así que la voy a dejar para un post aparte.

Básicamente de lo que se trata es de manejar el estado global de la aplicación usando la arquitectura Flux 🤔️

Sí, ya sé, esto no se entiende, pero más adelante va a quedar más claro.

Entonces, si todavía no saben usar Vuex, ¿cómo pueden solucionar el problema de la comunicación entre componentes no emparentados? Usando stores.

Stores

Las stores son como una versión muy simplificada de Vuex. Tienen una estructura parecida a la de Vuex pero su funcionalidad es mucho más limitada. De todas formas, en aplicaciones simples pueden ser usadas sin problemas

Su implementación es muy simple, mucho más simple que la de los observables, los event buses y los mixins, y también mucho menos problemática.

Para crear una store es así:

En un archivo .js aparte (no .vue) creamos un objeto normal de JavaScript con el nombre de la store (en este caso cartStore) y dentro declaramos las propiedades y métodos que serán compartidas:

js
// src/stores/cartStore.js

export const cartStore = {

  cart: [],
  
  cartTotal() {
    return this.cart.reduce((total, item) => total + item.qty, 0)
  },

  cartTotalPrice() {
    return this.cart.reduce((total, item) => total + item.subtotal, 0)
  },
  
  addToCart(product) {
    this.cart.push({
      ...product,
      qty: 1, 
      subtotal: product.price
    })    
  },
}

Luego importamos este objeto en el componente donde necesitemos usarlo, por ejemplo ProductCard, y lo declaramos como una propiedade del objeto data. Al ser insertado en data el objeto se vuelve reactivo 🥳️

js
// src/components/ProductCard.vue

import { cartStore } from './stores/cartStore'

export default {

  props: {
    product: {
      id: String,
      nombre: String,
      imagen: String,
      precio: Number,
      stock: Number
    }
  },

  data: () => ({
    // No es necesario poner 'cartStore: cartStore' porque en JS
    // si el key y el valor son iguales se pueden abreviar así:
    cartStore
  }),

  methods: {
    addToCart() {
      // Se pueden usar los métodos de la store dentro del componente
      this.cartStore.addToCart(this.product)
    }
  }
}

Y en cualquier otro componente de la aplicación donde necesitemos la data del carrito lo importamos nuevamente. Por ejemplo, en el componente CartVue:

js
import { cartStore } from './stores/cartStore'

export default {
  // Nuevamente hay que insertarla en data
  data: () => ({
    cartStore
  }),

  computed: {
    // Usar una computed para obtener el contenido del carrito
    cart() {
      return this.cartStore.cart
    },
    cartTotal() {
      // Con esta computed obtengo el total de productos en el carrito
      return this.cartStore.cartTotal()
    },
    cartTotalPrice() {
      // Y con esta el costo total de la compra
      return this.cartStore.cartTolalPrice()
    }
  }
}

Y así la información del carrito queda sincronizada entre todos los componentes 🥳️

Patrón Flux

La única advertencia a tener en cuenta al usar stores es que las propiedades de la store nunca deben ser mutadas desde los componentes. Los métodos en los componentes (como el método addToCart acá arriba) solamente deben ser usados para disparar métodos propios de la store pero nunca deben modificar propiedades de la store en forma directa, como por ejemplo así:

js
// NO HAGAN ESTO 🙏️ 🙏️ 🙏️

methods: {
  addToCart() {
    this.cartStore.cart.push(this.product)
  },

  increment(index) {
    this.cartStore.cart[index].qty++
  }
}

💡️

Éste es uno de los principios básicos del patrón Flux: la store debe ser la única fuente de verdad y el único lugar en donde ocurren las mutaciones.

Loggeando la store

Tal como recomienda la documentación de Vue es aconsejable loggear cada mutación en la store para que, si ocurre algún bug, poder ver cuál fue el cambio que provocó el error y cuándo ocurrió.

js
// src/stores/cartStore.js

const { stringify } = JSON

export const cartStore = {

  cart: [],
  
  cartTotal() {
    return this.cart.reduce((total, item) => total + item.qty, 0)
  },

  cartTotalPrice() {
    return this.cart.reduce((total, item) => total + item.subtotal, 0)
  },
  
  addToCart(product) {
   
    console.log(`
      addToCart at: ${new Date()}
      param: ${stringify(product)}
    `)

    this.cart.push({
      ...product,
      qty: 1, 
      subtotal: product.price
    })    
  },
  removeFromCart(id) {

    console.log(`
      removeFromCart at: ${new Date()}
      param: ${id} 
    `)

    const index = this.cart.findIndex(item => item.id === id)
    this.cart.splice(index, 1)
  }
}

Stores + Composition API

Si estamos usando la Composition API de Vue 3, la creación de stores es bastante similar.

La diferencia es que, en este caso, para usar la store dentro de los componentes no es necesario declararla nuevamente para que se vuelva reactiva, ya que Vue 3 permite generar reactividad por fuera de los componentes. Ésta es una de las características principales de lo que en Vue 3 suele llamarse composables, pero es un tema largo que quedará para otro post... ✍️

La forma de crear la composable store es así:

En un archivo .ts (no .vue) importamos el método reactive de Vue 3 y le pasamos el objeto de la store como parámetro.

TL;DR: No es obligatorio usar TypeScript para crear composable stores en Vue 3. Si lo que sigue les parece demasiado enroscado pueden ver este otro ejemplo más simple que subí a GitHub, que no usa TS.

ts
// src/stores/cartStore.ts
import { reactive } from 'vue'

// TypeScript... 🤷‍♂️️
interface Product {
  id: string
  title: string
  price: number
}
interface CartItem extends Product {
  qty: number
  total: number
}
// Acá va el método reactive para declarar la store
export const cartStore = reactive({

  cart: [] as CartItem[],

  cartTotal(): number {
    return this.cart.reduce(
      (total: number, item: { qty: number }) => total + item.qty, 0)
  },

  cartTotalPrice(): number {
    return this.cart.reduce(
      (total: number, item: { total: number }) => total + item.subtotal, 0)
  },
  
  productInCart(id: string): CartItem | undefined {
    return this.cart.find(
      (product: { id: string }) => product.id === id)
  },

  addToCart(product: Product): void {
    const inCart = this.productInCart(product.id)
    if (inCart) {
      inCart.qty++; inCart.subtotal = inCart.price * inCart.qty;
    } else {
      this.cart.push({...product, qty: 1, subtotal: product.price})
    }
  }
})

TypeScript

En Vue 3 TypeScript se puede usar en forma nativa, sin necesidad de instalarlo ni compilarlo. Aunque no soy muy fanático de TypeScript tengo que reconocer que en los últimos años TS fue ganando cada más terreno en el desarrollo frontend, tanto en Vue como en Svelte y React.

Usando la composable store en componentes

Para usar la composable store en cualquier componente lo único que necesitamos es importarlo y usar sus propiedades dentro de <script setup> en forma directa, sin necesidad de volver a declararlo como reactivo.

Por ejemplo, en el componente ProductCard, podemos usar el método cartStore.addToCart():

html
<script setup lang="ts">

import { cartStore } from '../stores/cartStore'

interface Product {
  product: {
    id: string
    title: string
    price: number
    imgsrc: string
  }
}

const { product } = defineProps<Product>()

const addToCart = () => cartStore.addToCart(product)

</script>

Y en el componente CartCounter podemos acceder al total de productos en el carrito como una computed property:

html
<template>
  <p class="cart-total">{{ total }}</p>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { cartStore } from '../stores/cartStore'

const total = computed<number>(() => cartStore.cartTotal())
</script>

Y en el componente CartTable, para acceder al contenido del carrito:

html
</template>

<script setup lang="ts">

import type { Ref } from 'vue'
import { computed } from 'vue'
import { cartStore } from '../stores/cartStore'

interface Product {
  id: string
  title: string
  price: number
}
interface CartItem extends Product {
  qty: number
  total: number
}
// TypeScript + Vue 3 = 🥵️ 
const cart: Ref<CartItem[]> = computed<CartItem[]>(() => cartStore.cart)
</script>

Stores vs. Vuex vs. Pinia

En aplicaciones grandes y complejas siempre hay que usar Vuex (o Pinia, la librería de manejo de estado para Vue 3). Esta store simple sólo puede usarse en aplicaciones de escala pequeña o mediana.

Por ejemplo, Vuex y Pinia mantienen un historial de todos los estados de la store, lo cual facilita el debugging, mientras que en la store simple el estado es efímero.

Y si están usando un meta-framework para Server-Side Rendering con Vue (como, por ejemplo, Nuxt) no es posible usar stores simples, hay que usar únicamente Vuex o Pinia, ya que persistir el estado de una store simple del lado del servidor puede producir una falla de seguridad bastante compleja, la así llamada cross request state pollution 😬️

Pero en el caso de una SPA con Client-Side Rendering la store simple funciona en forma bastante similar a Pinia. Por ejemplo, en Pinia el manejo de estado está dividido en tres secciones: State, Getters y Actions.

Y estas mismas tres secciones se pueden aplicar a nuestra store:

js
export default {

  // STATE
  cart: [],
  
  // GETTERS
  cartTotal() {
    return this.cart.reduce((total, item) => total + item.qty, 0)
  },
  cartTotalPrice() {
    return this.cart.reduce((total, item) => total + item.subtotal, 0)
  },
  
  // ACTIONS
  addToCart(product) {
    this.cart.push({
      ...product,
      qty: 1,
      subtotal: product.precio
    })    
  },
}

El state es como el objeto data en los componentes: contiene variables (o, mejor dicho, propiedades) que van a ir cambiando. Por ejemplo, el array cart va cambiando a medida que se agregan o se quitan productos del carrito.

Los getters con como las computed: no producen cambios en el estado sino que obtienen algún valor a partir de éste. Por ejemplo, se puede usar un getter para obtener la cantidad total de productos en el carrito.

Las actions con como los methods: producen cambios en el estado. Por ejemplo, al agregar o quitar un producto del carrito, el estado del carrito (cart: []) es modificado (o sea, mutado).

Aunque esta store simple tiene un funcionamiento más básico, su estructura es similar a la de Pinia (y en menor grado, a la de Vuex) así que si se acostumbran a usar stores pasar luego a Pinia (o a Vuex) les va a resultar mucho más fácil 👍️

Ejemplo de store simple

En este repositorio pueden ver un ejemplo de una store simple: un contador que sincroniza su estado entre distintos componentes, algunos usando la Options API de Vue 2, y otros usando la Composition API de Vue 3 (sin TypeScript):

https://github.com/dav-leda/vue3-counter-store

Y el deploy en GitHub Pages:

https://dav-leda.github.io/vue3-counter-store/