Estado compartido
En resumen: El estado compartido en Vue viene de provide/inject o de un store de Pinia, y los componentes lo leen directamente, sin props por cada nivel.
Problema
El contador vuelve a marcar 0
El carrito está elevado a App, como en la lección sobre elevar el estado. Desde ahí baja como props: a Header y desde él a CartBadge, a Catalog, desde él a ProductList y desde él a cada ProductCard. Alguien reescribió Header, y el contador se paró.
Consola
Añade pan, leche y otra vez pan. Las tarjetas cuentan, y la cabecera dice «Carrito (0)». App pasa count a Header, pero el nuevo Header no se lo pasa a CartBadge. Header no usa el carrito. Solo tenía que pasarlo, y justo eso olvidó.
La idea
Un proveedor arriba, consumidores abajo
Pasar datos por componentes que no los usan se llama prop drilling. Con dos niveles es normal. Con cinco, cada componente del camino depende del carrito, y una prop olvidada rompe la cadena.
Las dos bibliotecas tienen una forma de dar datos a descendientes lejanos sin los componentes del medio:
- El proveedor es el componente dueño de los datos. Dice: «todo lo que hay debajo de mí puede leer esto».
- El consumidor es el componente que los usa. Los toma directamente, sin importar cuántos niveles haya en medio.
En Vue son provide e inject:
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));provide('cart', { items, count, add });</script>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script>provide('cart', …) da un valor bajo una clave a todos los descendientes del componente. inject('cart') lo toma del ancestro más cercano que lo proporcionó. Se pasan los refs (items, count), no sus valores, para que los consumidores sigan siendo reactivos.
Los datos siguen teniendo un dueño, y los cambios siguen pasando por él: add es la función del proveedor. Solo cambió el camino.
Míralo
Mira las dos formas
Al principio el carrito baja nivel por nivel, y la cadena se rompe por la mitad. Después App se convierte en proveedor y los dos consumidores leen directamente.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0));function add(product) { /* … */ }provide('cart', { items, count, add });</script><template> <Header /> <Catalog /></template>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script><template> <a href="/cart">Carrito ({{ count }})</a></template>
<!-- ProductCard.vue --><script setup>const { add } = inject('cart');</script><template> <button @click="add(product)">Añadir</button></template>Paso a paso, en palabras
- El carrito vive en App. Dos componentes muy abajo lo necesitan: CartBadge en la cabecera y ProductCard en el catálogo.
- Solo con props, el carrito baja nivel por nivel. Header, Catalog y ProductList no lo usan. Solo lo pasan.
- Alguien reescribe Header y olvida count. La cadena se rompe por la mitad y el contador muestra 0. Eso es el prop drilling.
- En su lugar: App llama a provide('cart', …). Cualquier descendiente de App puede leer el carrito.
- CartBadge y ProductCard lo leen con inject('cart'). Los componentes intermedios no saben nada del carrito.
- Un clic en «Añadir» llama a add(product). La función vino del proveedor, por eso el cambio ocurre en App.
- El carrito de App cambia y cada componente que lo lee se dibuja de nuevo: el contador dice 1, la tarjeta dice 1.
- Los datos siguen teniendo un solo dueño, App. Solo cambió el camino: no por cada nivel, sino directo desde el proveedor.
Los componentes del medio ya no saben nada del carrito. El nuevo Header lo puedes reescribir tantas veces como quieras.
Pruébalo
Quita el drilling
El carrito baja como props por Header y Catalog, y Header pierde count. Da el carrito desde arriba. Header, Catalog y ProductList no reciben nada sobre el carrito, y CartBadge y ProductCard lo leen directamente. La comprobación añade pan, leche y otra vez pan y mira las tarjetas y el contador.
Consola
Solución
App.vue<script setup>
import { ref, computed, provide } from 'vue';
import Header from './Header.vue';
import Catalog from './Catalog.vue';
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 });
}
provide('cart', { items, count, add });
</script>
<template>
<Header />
<main>
<Catalog />
</main>
</template>
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">La tienda</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { inject } from 'vue';
const { count } = inject('cart');
</script>
<template>
<a href="/cart">Carrito ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Productos</h1>
<ProductList />
</section>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.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 },
];
</script>
<template>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed, inject } from 'vue';
import { money } from './money.js';
const props = defineProps(['product']);
const { items, add } = inject('cart');
const qty = computed(() => items.value.find((i) => i.id === props.product.id)?.qty ?? 0);
</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="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; }
En App.vue llama a provide('cart', { items, count, add }) y quita las props y @add de Header y Catalog. CartBadge lee const { count } = inject('cart'). ProductCard lee items y add y calcula la cantidad con computed. Catalog y ProductList ya no tienen defineProps ni defineEmits para el carrito.
La comprobación detecta provide('cart', { count: count.value, … }): se pasa el número de ese momento, no el ref, y el contador se queda en 0. También detecta qty sin computed en ProductCard: setup se ejecuta una vez, así que el número no cambia.
La comprobación solo ve el comportamiento. Si vuelves a pasar count por Header, pasará. El objetivo aquí es otro: que el medio no sepa del carrito.
La idea
Un store para el carrito
El carrito crece: añadir, quitar, cambiar la cantidad, vaciarlo. Si cada acción es una función en App, App se llena de lógica que no le corresponde. Por eso el estado compartido se muda a un archivo aparte, un store: los datos y todo lo que los cambia, en un solo lugar.
Para los stores Vue usa Pinia, la biblioteca oficial para el estado compartido. Un store se crea con defineStore y se escribe como <script setup>: un ref es el estado, un computed se deriva de él, las funciones son las acciones.
export const useCartStore = defineStore('cart', () => { const items = ref([]); const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0)); function add(product) { /* … */ } return { items, count, add };});Pinia se instala una vez, en main.js: createApp(App).use(createPinia()).mount('#app'). Cada componente llama a useCartStore() y recibe el mismo store. No hace falta ningún provide en App.
<script setup>import { storeToRefs } from 'pinia';const cart = useCartStore();const { count } = storeToRefs(cart);</script>El store es un objeto reactive: en la plantilla escribes cart.count, sin .value. Si quieres desarmarlo en variables, usa storeToRefs(cart). Da refs que siguen conectados al store. Las acciones (cart.add) se pueden tomar directamente.
Consola
cart.js: ahí está toda la lógica del carrito. App.vue ya no tiene estado, y main.js instala Pinia.Pruébalo
Encuentra el error
Elige la línea con el error.
const { count } = useCartStore() toma el número de ese momento y lo corta del store. Debería ser const { count } = storeToRefs(useCartStore()), o cart.count en la plantilla.La idea
Cuándo compartir y cuándo no
El estado compartido es cómodo, y por eso es tentador meterlo todo ahí. Pero todo lo que hay en él se puede leer y cambiar desde cualquier sitio, y eso hace los errores más difíciles de encontrar. Sigue valiendo la regla de la lección sobre elevar: tan alto como haga falta, y no más.
- Compartido: lo que necesitan partes lejanas del árbol y lo que vive mientras el usuario está en el sitio. El carrito, el usuario con sesión iniciada, el idioma, el tema.
- En el componente: los campos de un formulario, si el menú está abierto, qué pestaña está elegida, el texto del cuadro de búsqueda. Lo necesita un componente (o su padre) y se va con él.
- Los datos del servidor no son estado compartido. La lista de productos y los pedidos tienen un dueño, y el dueño es el servidor. En un store son una copia que hay que refrescar. El componente que los muestra los carga, como en la lección sobre datos de una API.
El estado compartido no cambia el sentido: los datos parten de un dueño, y los cambios pasan por sus funciones.
Pruébalo
Rellena el hueco
CartBadge el count del store para seguir conectado a él?<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = (useCartStore());
</script>storeToRefs: da un ref por cada pieza de estado y cada campo calculado del store. Un ref desestructurado sigue conectado, un número desestructurado no.Más precisamente, para curiosos
Un store con opciones. defineStore también acepta un objeto: state: () => ({ items: [] }), getters y actions. Es lo mismo escrito como Options API. Pinia admite los dos estilos.
provide/inject o Pinia. provide viene incluido y sirve para lo que necesita un subárbol (los ajustes de una tabla, un formulario con campos anidados). Pinia es para el estado de toda la aplicación: se ve en las Vue DevTools, se prueba por separado y no depende de qué componente esté arriba.
Un símbolo como clave. En lugar de la cadena 'cart', provide suele usar un Symbol() exportado desde un archivo común. Así dos proveedores distintos no pueden tener la misma clave por casualidad.
inject con valor por defecto. inject('cart', null) no escribe ninguna advertencia cuando nadie proporcionó la clave, y devuelve null.
El store vive mientras la página está abierta. Al recargar se pierde. Un carrito que debe sobrevivir se guarda en localStorage o en el servidor.
Las comprobaciones ven el comportamiento. Las tareas de aquí no ven si el carrito viene de props, de un contexto o de un store. Comprueban que el contador y las tarjetas muestren lo mismo.
Ejercicio
La página del carrito
En la parte principal está CartSummary: un encabezado El carrito, filas Pan blanco × 2 con un botón Quitar, Total: 4,00 €, y El carrito está vacío cuando está vacío. Ahora tiene su propio carrito que nadie rellena. Añade al store el quitar y un total, y haz que CartSummary lea el carrito de ahí. La comprobación añade tres productos, los quita y mira el contador, las tarjetas y el total.
Consola
Solución
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
createApp(App).use(createPinia()).mount('#app');
App.vue<script setup>
import Header from './Header.vue';
import Catalog from './Catalog.vue';
import CartSummary from './CartSummary.vue';
</script>
<template>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</template>
cart.jsimport { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * 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(id) {
items.value = items.value.filter((i) => i.id !== id);
}
return { items, count, total, add, remove };
});
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">La tienda</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = storeToRefs(useCartStore());
</script>
<template>
<a href="/cart">Carrito ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Productos</h1>
<ProductList />
</section>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.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 },
];
</script>
<template>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed } from 'vue';
import { useCartStore } from './cart.js';
import { money } from './money.js';
const props = defineProps(['product']);
const cart = useCartStore();
const qty = computed(() => cart.items.find((i) => i.id === props.product.id)?.qty ?? 0);
</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="cart.add(product)">
Añadir
</button>
</li>
</template>
CartSummary.vue<script setup>
import { useCartStore } from './cart.js';
import { money } from './money.js';
const cart = useCartStore();
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">El carrito</h2>
<p v-if="cart.items.length === 0">El carrito está vacío</p>
<ul>
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Quitar ${item.name}`" @click="cart.remove(item.id)">
Quitar
</button>
</li>
</ul>
<p>Total: {{ money(cart.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; }
En cart.js añade total con computed y una acción remove(id) que sustituya items.value por un array filtrado, y devuélvelos desde el store. CartSummary llama a const cart = useCartStore() y usa en la plantilla cart.items, cart.total y cart.remove(item.id).
La comprobación detecta const { items, total, remove } = useCartStore(): las filas se ven, porque el array es el mismo, pero total es el número del momento de la creación y el total se queda en 0.
La comprobación detecta también una copia del carrito en CartSummary: está vacía al crearse y no sigue al store.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Estado derivado · Estado compartido: hacia arriba · Datos de una API en un componente · Formularios en un componente
- Continúa en: Enrutamiento · Del sandbox al proyecto · El escaparate de la tienda
- Ejercicios sobre el tema: Efectos, datos y formularios