Skip to content
En esta página

Comunicación entre componentes

Desde el surgimiento de React la arquitectura basada en componentes se convirtió en el patrón de diseño por default del desarrollo web. Actualmente todos los frameworks de frontend (Vue, Angular, Svelte, Preact, Solid, Alpine) siguen este patrón.

Al tener nuestro frontend seccionado en componentes separados, cada uno con su estado y su lógica propios, la construcción del frontend es mucho más ordenada. Además, esto permite que el código sea más DRY (Don't Repeat Yourself) ya que los componentes pueden ser reutilizables en distintos lugares de la aplicación.

Pero dividir la aplicación en componentes también incrementa la complejidad, sobre todo cuando se trata compartir información entre ellos.

A continuación vamos a ver las distintas formas en que los componentes se pueden comunicar entre sí:

De componente padre a componente hijo

Supongamos que tenemos un componente padre (en realidad podría llmarse componente madre, pero bueno, la convención es llamarlo así 🤷‍♂️️) que trae información de productos de una API y dos componentes hijos: ProductCard, para mostrar los productos, y ErrorMessage, para mostrar un mensaje de error en caso de que llamada a la API falle:

html
<template>
  <div class="container">
    
    <ErrorMessage v-if="error" :error-message="error"/>

    <ProductCard 
      v-else
      v-for="product in products" :key="product.id"
      :product="product"
    />

  </div>
</template>

<script>

import ProductCard from '@/components/ProductCard.vue'
import ErrorMessage from '@/components/ErrorMessage.vue'

const apiUrl = 'https://mi-backend.com/api/productos/'

export default {

  name: 'App',

  components: {
    ProductCard, ErrorMessage,
  },

  data: () => ({
    products: [],
    error: ''
  }),

  created() {
    this.fetchProducts(apiUrl)
  },

  methods: {
    async fetchProducts(url) {
      try {
        this.products = await (await fetch(url)).json()
      } catch (err) {
        // En vez de mostrar el error por consola
        // mostrárselo al usuario en la vista
        this.error = err
      }
    }
  }
}

</script>

Si la llamada a la API falla, en lugar de mostrar las ProductCards se muestra el componente ErrorMessage con el mensaje de error que devuelve la API.

Y, ¿cómo recibe el componente ErrorMessage el mensaje de error? Mediante props:

js
<template>
  <div class="error">
    <h3>Error de conexión:</h3>
    <p>{{ errorMessage }}</p>
  </div>
</template>

<script>

export default {

  name: 'ErrorMessage',

  props: {
    // El tipo de dato en este caso es Error, no String
    errorMessage: Error
  }
}
</script>

En el componente padre la prop que se le pasa el hijo se escribe en formato kebab-case: error-message. Esto es porque en HTML no hay diferencia entre mayúsculas y minúsculas. Por convención se separan las palabras con guiones:

html
<ErrorMessage 
  v-if="error" 
  :error-message="error"
/>

Y en el componente hijo se escribe en formato camelCase: errorMessage, que es la convención para nombres de variables o funciones en JavaScript:

js
props: {
  errorMessage: Error
}

💡️ kebab-case -> camelCase

Al igual que con los nombres de los componentes, Vue convierte los nombres de las props de un formato a otro automáticamente.

De componente hijo a componente padre

Ahora supongamos que tenemos un componente padre (App.vue) que contiene el componente de una ventana modal (ModalVue):

html
<template>
  <div>
  
    <button @click="openModal">Abrir Modal</button>

    <ModalVue
      :open-modal="showModal" 
      @close-modal="closeModal"
    />

  </div>
</template>

<script>

import ModalVue from '@/components/ModalVue.vue'

export default {

  name: 'App',

  components: {
    ModalVue
  },

  data: () => ({ 
    showModal: false
  }),

  methods: {
    openModal() {
      this.showModal = true
    },
    closeModal() {
      this.showModal = false
    }
  }
}

</script>

Cuando cliqueamos en el botón Abrir Modal el valor de showModal pasa de false a true y la ventana se abre. Pero para cerrar la ventana, ¿cómo hace ModalVue para modificar el valor de showModal que está dentro de App.vue? Con un emit:

html
<template>

  <!-- <transition> es un componente nativo de Vue que se usa para
  darle estilos de CSS a las transiciones entre componentes -->
  <transition name="modal">
    
    <div v-if="openModal" class="modal-mask">

      <div class="modal-container">

        <h3>Título</h3>

        <p>Texto</p>
        
        <button @click="closeModal">Cerrar modal</button>
    
      </div>

    </div>

  </transition>

</template>

<script>

export default {

  name: 'ModalVue',

  props: {
    openModal: Boolean
  },

  emits: ['close-modal'],

  methods: {
    closeModal() {
      // El emit debe nombrarse usando kebab-case, no camelCase
      this.$emit('close-modal')
    }
  }
}

</script>

ModalVue recibe de App.vue una prop llamada openModal con el valor de showModal. El v-if en ModalVue hace que la ventana se muestre cuando el valor de la prop openModal es true.

Luego, al cliquear en el botón Cerrar Modal dentro de ModalVue se ejecuta el método closeModal(). En este método hay un emit llamado close-modal que envía un evento de ModalVue a App.vue. Cuando App.vue recibe el evento @close-modal ejecuta el método closeModal() que cambia el valor de showModal de true a false y la ventana se cierra, porque automáticamente ModalVue recibe el nuevo valor de showModal mediante la prop.

💡️

A diferencia de las props los emits deben nombrarse siempre en formato kebab-case, no camelCase, ya sea que estén en el template o en el script.

Pero podríamos decir: esto se puede hacer más fácil, directamente cambio el valor de la prop openModal dentro de ModalVue y la ventana se va a cerrar, porque el valor dentro del v-if pasa a ser false:

js
methods: {
  closeModal() {
    this.openModal = false
  }
}

Sí, funciona, pero Vue les va a mostrar el siguiente error por consola:

❌️

Vue warn: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "openModal"

found in

---> ModalVue at /src/components/ModalVue.vue App at /src/App.vue Root

Es decir, nunca debemos cambiar en forma directa el valor de una prop, porque el valor podría ser pisado cuando el componente padre renderice nuevamente.

En este caso particular funciona igual, porque el valor inicial de showModal en el componente padre también es false. Pero podría haber casos en que el nuevo valor que le damos a la prop difiera del valor inicial y ahí la aplicación se rompe 😬️

Emit + Data

En el ejemplo anterior el emit no envía información de ModalVue a App.vue, solamente es un evento que dispara un método dentro de App.vue (closeModal).

En el caso de que necesitemos enviar información del componente hijo al padre, la podemos poner en el segundo parámetro del emit:

js
export default {

  data: () => ({
    dataHijo: 'Data de componente hijo a componente padre' 
  }),

  methods: {
    emitirData() {
      // El primer param es el nombre del emit y el segundo la data:
      this.$emit('emitir-data', this.dataHijo)
    }
  }
}

Y si están emitiendo un valor pasándoselo por parámetro desde el template al method recuerden que si ese valor es un String, deben usar comillas simples, no dobles, para el param dentro del template (en este caso, emitirData('un dato'):

html
<template>

  <button @click="emitirData('un dato')">Emitir Data</button>

</template>

<script>
export default {

  data: () => ({
    dataHijo: 'Data de componente hijo a componente padre' 
  }),

  methods: {
    emitirData(dato) {
      this.$emit('emitir-data', dato)
    }
  }
}
</scritp>

Y si es más de un dato, puede emitirse dentro de un objeto, creando un key para cada dato (en este caso los keys son emit1 y emit2):

js
export default {

  data: () => ({
    dataHijo1: 'Data 1 de componente hijo a componente padre',
    dataHijo2: 'Data 2 de componente hijo a componente padre',
  }),

  methods: {
    emitirData() {
      this.$emit('emitir-data', {
        // Objeto con pares key-value
        emit1: this.dataHijo1,
        emit2: this.dataHijo2
      })
    }
  }
}

Luego, en el componente padre, se puede acceder al objeto recibido, haciendo referencia a cada una de sus propiedades (en este caso, emit1 y emit2):

html
<template>

  <ComponenteHijo @emitir-data="recibirData"/>

  <p>{{ dataRecibida1 }}</p>
  <p>{{ dataRecibida2 }}</p> 

</template>

<script>

import ComponenteHijo from './components/ComponenteHijo.vue'

export default {

  components: {
    ComponenteHijo
  },

  data: () => ({
    dataRecibida1: '',
    dataRecibida2: ''
  }),

  methods: {
    recibirData(objetoRecibido) {
      // El objeto tiene dos propiedades: emit1 y emit2
      this.dataRecibida1 = objetoRecibido.emit1
      this.dataRecibida2 = objetoRecibido.emit2
    }
  }
}
</script>

O, para abreviar, en el método recibirData() se puede usar una desestructuración de parámetros:

js
methods: {
    // Param destructuring: ({ param1, param2 })
    recibirData({ emit1, emit2 }) {
      this.dataRecibida1 = emit1
      this.dataRecibida2 = emit2
    }
  }

Comunicación entre componentes no emparentados

Supongamos que tenemos un componente padre (App.vue) que tiene como componentes hijos a NavBar (la barra de navegación), ErrorMessage y ProductCard:

html
<template>
  <div>

    <NavBar/>
    
    <ErrorMessage v-if="error" :error="error"/>

    <ProductCard
      v-else
      v-for="product in products" :key="product.id"
      :product="product"
    />

  </div>
</template>

<script>

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
  },

  // etc...
}
</sript>

Y ProductCard tiene un método para agregar un producto al carrito (addToCart):

js
<template>
  <div class="card">

    <img :src="product.imagen" :alt="product.nombre"/>

    <h2>{{ product.nombre }}</h2>
    <p> $ {{ product.precio }}</p>

    <footer>
      <button @click="addToCart">Agregar al carrito</button>
    </footer>

  </div>
</template>

A su vez NavBar tiene como hijo al componente que muestra la cantidad de productos en el carrito (CartCounter), un componente para el Login y otro para el Signup:

html
<template>
  <nav>
    
    <h1>Titulo</h1>

    <LoginVue/>
    <SignupVue/>

    <CartCounter/>

  </nav>
</template>

Y a su vez CartCounter tiene como hijo al componente del carrito (CartVue) que se muestra al cliquear en CartCounter, usando el método openCart:

html
<template>

  <p @click="openCart">{{ cartTotal }}</p>

  <CartVue v-if="showCart"/>

</template>

<script>

import CartVue from './components/CartVue.vue'

export default {

  components: {
    CartVue
  },

  data: () => ({
    showCart: false,
    cartTotal: 0
  }),

  methods: {
    openCart() {
      this.showCart = true
    }
  }
}

</script>

La estructura de nuestros componentes quedaría así:

diagram

Entonces, ¿cómo hacemos para que cada vez que se agrega un producto al carrito en ProductCard esto se vea reflejado en el componente CartVue?

Una posibilidad sería poner un emit en ProductCard que envíe el producto agregado a App.vue:

js
methods: {
  addToCart() {
    this.$emit('add-to-cart', this.product)
  }
}

Luego, en App.vue hay que recibir este emit con un method (addToCart):

html
<template>
  <div>

    <NavBar :cart="cart"/>
    
    <ErrorMessage v-if="error" :error="error"/>

    <ProductCard
      v-else
      v-for="product in products" :key="product.id"
      :product="product"
      @add-to-cart="addToCart"
    />

  </div>
</template>

<script>

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

export default {

  components: {
    NavBar, ErrorMessage, ProductCard
  },

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

  methods: {
    addToCart(product) {
      this.cart.push(product)
    }
  }
}
</sript>

Y luego crear una cadena de props que transmita el array cart desde App.vue a CartVue, pasando por NavBar y CartCounter 😐️

Esto es lo que se suele llamar prop drilling y es considerado una mala práctica porque puede fallar muy fácilmente si la cadena de props se rompe en el camino por algún error.

Para evitar el prop drilling Vue tiene distintas soluciones que podemos ver en el artículo siguiente.