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:
<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:
<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:
<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:
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):
<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:
<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
:
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:
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')
:
<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):
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):
<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:
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:
<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
):
<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:
<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
:
<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í:
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:
methods: {
addToCart() {
this.$emit('add-to-cart', this.product)
}
}
Luego, en App.vue hay que recibir este emit con un method (addToCart):
<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.