Ir al contenido
wannacode

Estado compartido: hacia arriba

En resumen: Subir el estado (lifting state up) lleva el estado compartido al padre común más cercano: los datos bajan como props y los cambios suben como evento (emit).

Problema

El contador de arriba no se entera

La tienda tiene arriba un contador del carrito y debajo una lista de productos. Añade pan, leche y otro pan.

Vue

La tarjeta del pan dice «En el carrito: 2», y arriba pone «Carrito (0)». El carrito vive en ProductList: ahí se pulsa el botón y ahí se puso el estado. CartBadge es otro componente, hermano de ProductList, y tiene su propio número que nadie cambia. Los dos componentes tienen dos carritos, y cada uno tiene razón con el suyo.

La idea

Hasta el padre común

Un componente ve solo su propio estado y las props que le pasa su padre. Un hermano no ve a su hermano. Así que dos sitios que muestran un carrito no pueden tener cada uno el suyo.

La solución es llevar el estado al padre común más cercano de los dos. Aquí es App. Esto se llama subir el estado (lifting state up):

  1. App guarda items y calcula count.
  2. Los datos bajan como props: count a CartBadge, items a ProductList.
  3. Los cambios suben como mensaje: el botón de ProductCard no cambia el carrito, le dice a App: «añade este producto».
<script setup>
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) { /* cambia items */ }
</script>
<template>
<header><CartBadge :count="count" /></header>
<main><ProductList :products="PRODUCTS" :items="items" @add="add" /></main>
</template>

El hijo declara con defineEmits(['add']) qué eventos envía, y los envía con emit('add', product). El padre escucha con @add="add", igual que escucha @click en un botón.

Ahora CartBadge y ProductList ya no tienen estado propio para el carrito. Muestran lo que les da App.

Míralo

Mira cómo sube

Al principio el carrito está en ProductList. Después sube a App y fluye desde allí hacia abajo.

App.vue
<script setup>
const items = ref([]);
const count = computed(() =>
items.value.reduce((s, i) => s + i.qty, 0),
);
function add(product) {
plusOne(items.value, product);
}
</script>
<template>
<CartBadge :count="count" />
<ProductList :items="items" @add="add" />
</template>
<!-- ProductList.vue -->
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p"
:qty="qtyOf(items, p)" @add="(product) => emit('add', product)" />
<!-- ProductCard.vue -->
<button @click="emit('add', product)">…</button>
Paso a paso, en palabras
  1. CartBadge y ProductList son hermanos: los dos son hijos de App. Al principio el carrito vive en ProductList, y CartBadge tiene su propio contador.
  2. Un clic en «Añadir» llega a ProductList y la lista cuenta 1. CartBadge no ve ese estado: un hermano no lee el estado de su hermano. Se queda en 0.
  3. La solución: llevar el carrito al padre común más cercano de los dos, App. El contador deja su propia copia.
  4. Desde App los datos bajan como props: count a CartBadge, items a ProductList y qty a cada ProductCard. Los dos hermanos leen ahora un solo carrito.
  5. El hijo no es dueño del carrito, así que no puede cambiarlo. El clic envía el evento add con el producto. ProductList lo pasa hacia arriba, y App lo escucha con @add y llama a add().
  6. App cambia items y las props nuevas bajan: el contador dice 2, la tarjeta dice 2. Nadie los actualizó a mano.
  7. La regla: el estado vive en un solo sitio, tan arriba como haga falta y no más. Los datos bajan como props, los cambios suben como eventos.

La dirección es la misma en cualquier app: datos hacia abajo, mensajes hacia arriba. Quien tiene los datos es el único que los cambia.

Pruébalo

Sube el carrito

El carrito está en ProductList, y CartBadge cuenta un número propio. Sube el carrito a App: CartBadge recibe la cantidad como prop, ProductList recibe items y avisa «Añadir» hacia arriba. La comprobación añade pan, leche y pan y mira las tarjetas y el contador.

Vue

La idea

El hijo avisa, el padre decide

ProductCard no sabe cómo está construido el carrito. Solo sabe que alguien pulsó «Añadir» en su producto. Así que avisa de lo que pasó, no de lo que hay que cambiar: «añade este producto», no «sube qty en la línea 2». App decide qué significa eso: una línea nueva o una unidad más.

Esto deja la tarjeta más simple, y puede estar en cualquier sitio: en el catálogo, en «Recomendados», en la búsqueda. No es dueña de los datos y no los toca.

El evento es un verbo sin on: add, remove, change. El hijo lo declara en defineEmits(['add']), lo envía con emit('add', product), y el padre escucha con @add. El segundo argumento de emit es lo que recibe la función del padre.

Pruébalo

Rellena el hueco

¿Qué va en el hueco para que el clic llegue al padre?

<script setup>
defineProps(['product']);
const emit = defineEmits(['add']);
</script>

<template>
  <button @click="('add', product)">Añadir</button>
</template>

Pruébalo

Encuentra el error

Un clic en «Añadir» no hace nada, y la consola está vacía. ¿Qué línea tiene la culpa?

Elige la línea con el error.

La idea

Cuando el árbol se hace profundo

¿Hasta dónde hay que subirlo? Tan arriba como haga falta, y no más. Si solo ProductCard usa algo (si la descripción está abierta), se queda en ella. Lo subes cuando un segundo componente tiene que verlo.

En una tienda real el carrito hace falta en muchos sitios: el contador de arriba, la lista, la página del producto, el pedido. El padre común está muy arriba, y el camino hasta ellos pasa por componentes que no usan el carrito, solo lo pasan hacia abajo. Esto se llama prop drilling. Con dos niveles es normal. Con cinco niveles, cada componente del camino tiene que saber de datos que no son suyos.

Para esto hay herramientas aparte: provide/inject y Pinia. Entregan los datos a descendientes lejanos sin pasar por los componentes del camino. Están en la lección sobre estado compartido del módulo «Efectos, datos y formularios». La regla sigue igual: los datos tienen un dueño, y los cambios pasan por él.

Más precisamente, para curiosos

Las props son de solo lectura. props.qty = 3 en Vue no cambia nada y escribe [Vue warn] Set operation on key "qty" failed: target is readonly en la consola. Un hijo que quiere un cambio avisa hacia arriba.

v-model en un componente es lo mismo. <QtyInput v-model="qty" /> es la forma corta de :modelValue="qty" y @update:modelValue="(v) => qty = v". Los datos siguen bajando como prop y siguen subiendo como evento.

Para qué sirve defineEmits. Los eventos declarados son documentación: ves lo que dice el componente. Y Vue los comprueba: si la tarjeta envía emit('added') y solo declaró add, la consola dice Component emitted event "added" but it is neither declared in the emits option….

Una copia no es subir el estado. ref(props.items) en el hijo no copia el array: es el mismo array reactivo del padre, y todo lo que se añade aparece. Pero items.value = items.value.filter(…) cambia solo la caja del hijo. El borrado nunca llega al padre, y los dos carritos se separan.

Ejercicio

El carrito en el lateral

Junto a la lista hay una tercera parte, CartSummary: el encabezado «Tu carrito», líneas como «Pan blanco × 2» con un botón «Quitar», «Total: 4,00 €» y «El carrito está vacío» cuando está vacío. Ahora mismo CartSummary guarda un carrito propio que nadie llena. Dale el carrito de App y haz que «Quitar» quite de él. La comprobación añade tres productos, los quita y mira el contador, las tarjetas y el total.

Vue

Test

Ponte a prueba

1. CartBadge está en la cabecera, ProductList está en la parte principal. Los dos son hijos de App. ¿Dónde debe estar el carrito?
2. ProductCard hace emit('add', product), y App escribe <ProductList :items="items" /> sin @add. ¿Qué pasa?
3. ¿Por qué ProductCard no cambia el carrito ella misma, sino que avisa hacia arriba?
4. El carrito lo necesita un componente cinco niveles por debajo de App. Los tres componentes del camino solo lo pasan hacia abajo. ¿Cómo se llama esto?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·