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.
Consola
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):
Appguardaitemsy calculacount.- Los datos bajan como props:
countaCartBadge,itemsaProductList. - Los cambios suben como mensaje: el botón de
ProductCardno cambia el carrito, le dice aApp: «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.
<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
- CartBadge y ProductList son hermanos: los dos son hijos de App. Al principio el carrito vive en ProductList, y CartBadge tiene su propio contador.
- 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.
- La solución: llevar el carrito al padre común más cercano de los dos, App. El contador deja su propia copia.
- 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.
- 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().
- App cambia items y las props nuevas bajan: el contador dice 2, la tarjeta dice 2. Nadie los actualizó a mano.
- 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.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.vue';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty++;
else items.value.push({ ...product, qty: 1 });
}
</script>
<template>
<header>
<a href="/">La tienda</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Productos</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Carrito ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">En el carrito: {{ qty }}</span>
<button type="button" :aria-label="`Añadir ${product.name}`" @click="emit('add', product)">
Añadir
</button>
</li>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
items, add y count suben a App.vue. CartBadge recibe :count="count". ProductList recibe :items="items" y envía add hacia arriba: defineEmits(['add']) y @add="(product) => emit('add', product)" en cada ProductCard. En ProductList ya no hay ref.
Si App no escribe @add="add", el evento se envía, pero nadie escucha. No pasa nada y la consola no muestra ningún error. La comprobación lo detecta por el comportamiento.
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>emit: la función de defineEmits. Envía el evento add con el producto, y el padre escucha con @add.Pruébalo
Encuentra el error
Elige la línea con el error.
App escucha @added, y la tarjeta envía add. Los nombres tienen que coincidir: @add="add".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….
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.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.vue';
import CartSummary from './CartSummary.vue';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 5, name: 'Leche fresca', price: 1.6 },
{ id: 7, name: 'Queso blanco', price: 7.9 },
];
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty++;
else items.value.push({ ...product, qty: 1 });
}
function remove(item) {
items.value = items.value.filter((i) => i.id !== item.id);
}
</script>
<template>
<header>
<a href="/">La tienda</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Productos</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
<CartSummary :items="items" @remove="remove" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Carrito ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">En el carrito: {{ qty }}</span>
<button type="button" :aria-label="`Añadir ${product.name}`" @click="emit('add', product)">
Añadir
</button>
</li>
</template>
CartSummary.vue<script setup>
import { computed } from 'vue';
import { money } from './money.js';
const props = defineProps(['items']);
const emit = defineEmits(['remove']);
const total = computed(() => props.items.reduce((sum, i) => sum + i.price * i.qty, 0));
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">Tu carrito</h2>
<p v-if="items.length === 0">El carrito está vacío</p>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Quitar ${item.name}`" @click="emit('remove', item)">
Quitar
</button>
</li>
</ul>
<p>Total: {{ money(total) }}</p>
</section>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
App.vue recibe una función remove(item) que filtra items. CartSummary recibe :items="items", declara defineEmits(['remove']), y el botón llama a emit('remove', item). App escucha con @remove="remove". Ahora el contador, las tarjetas y el carrito del lateral leen un solo estado, así que quitar se ve en los tres sitios.
ref(props.items) en CartSummary con el borrado en el sitio: los productos añadidos aparecen, porque el array es el mismo, pero items.value = …filter(…) cambia solo la caja de CartSummary. El contador de arriba se queda en 3.Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Condiciones: vacío, error, cargando · Estado · Actualizar objetos y arrays · Estado derivado
- Continúa en: Estado compartido · Enrutamiento · Velocidad: cuándo se vuelve a renderizar un componente · Probar componentes
- Ejercicios sobre el tema: Estado