Estado derivado
En resumen: El estado derivado es un valor que se calcula a partir del estado (una cantidad, un total, una lista filtrada) en lugar de guardarse aparte; computed guarda el resultado hasta el siguiente cambio.
Problema
El total se queda atrás
El carrito tiene pan blanco y leche fresca. Pulsa «+» en el pan: la cantidad y el total crecen. Ahora quita la leche.
Consola
La línea desaparece, la cantidad baja a 3, y el total se queda en 5,20 €. El componente guarda tres estados: items, count y total. Cada clic tiene que cambiar los tres, y la función de quitar cambia dos. Es la misma deriva que en la lección «Una lista a partir de datos», solo que dentro de un componente. La biblioteca dibuja exactamente lo que le das. Le das dos verdades sobre un mismo carrito.
La idea
Calcúlalo, no lo guardes
Pregunta por cada pieza de estado: ¿puedo calcularla a partir del otro estado o de las props? Si puedes, no es estado. La cantidad es la suma de las cantidades de items. El total es la suma de precio por cantidad. Las dos se calculan.
Un valor así se llama estado derivado (derived state). Guardas solo items y calculas el resto a partir de él cada vez que el componente se renderiza.
const items = ref(START);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));<script setup> se ejecuta una vez, cuando se crea el componente. Por eso el cálculo está en una función que se le da a computed. Vue recuerda que lee items y la ejecuta otra vez cuando items cambia. En la plantilla escribes {{ total }}, sin .value.
Ahora las funciones more y remove cambian solo items. Ya no queda nada que puedan olvidar.
Míralo
Mira los valores calculados y la copia
A la izquierda están el carrito y una copia del total, guardada aparte. A la derecha están los tres valores calculados.
<script setup>const items = ref(START);const savedTotal = ref(26.9);
const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0),);const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0),);const free = computed(() => total.value >= 30);
function more(item) { item.qty++; savedTotal.value += item.price;}
function remove(item) { items.value = items.value.filter((i) => i.id !== item.id);}</script>Paso a paso, en palabras
- A la izquierda está el estado: los elementos del carrito items. Debajo hay un segundo estado, savedTotal: el mismo total, guardado como copia.
- A la derecha están count, total y free: valores computed. Vue recuerda que leen items y los calcula de nuevo cuando items cambia: 26,90 €, faltan 3,10 € para el envío gratis.
- more(queso): items cambia y las tres flechas se encienden. El total es 34,80 €, el envío es gratis. more() también actualiza savedTotal a mano, así que la copia todavía es correcta.
- remove(queso): items cambia, y count, total y free lo siguen solos: 3,20 €. Para savedTotal en remove() nadie escribió una línea.
- La copia dice 34,80 €, el carrito es 3,20 €. Ningún error, solo un número falso: el estado y su copia se han separado.
- La regla: guarda solo lo que no se puede calcular. Lo que se deduce del estado se calcula a partir de él, y no hay ninguna copia que olvidar.
Los valores calculados siguen al carrito en cada cambio, porque no tienen memoria propia. La copia lo sigue solo mientras alguien se acuerde de actualizarla. La primera función que se olvide la deja mal.
Pruébalo
Calcula la cantidad y el total
El carrito guarda count y total como estados aparte, y remove() se olvida del total. Quita los dos estados y calcula la cantidad y el total a partir de items. La comprobación pulsa «+» en el pan, luego quita la leche y mira la cantidad y el total.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const items = ref([
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2 },
{ id: 5, name: 'Leche fresca', price: 1.6, qty: 1 },
]);
// Cantidad y total: calculados en cada render.
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 more(item) {
item.qty++;
}
function remove(item) {
items.value = items.value.filter((i) => i.id !== item.id);
}
</script>
<template>
<main>
<h1>Tu carrito</h1>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Más ${item.name}`" @click="more(item)">+</button>
<button type="button" :aria-label="`Quitar ${item.name}`" @click="remove(item)">Quitar</button>
</li>
</ul>
<p>Artículos: {{ count }}</p>
<p>Total: {{ money(total) }}</p>
</main>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
Solo queda items. La cantidad es items.reduce((sum, i) => sum + i.qty, 0), el total es lo mismo con i.price * i.qty. En Vue son computed: se calculan de nuevo cuando items cambia. more y remove ahora cambian solo el carrito.
Una trampa: const total = items.value.reduce(…) sin computed. <script setup> se ejecuta una vez, así que el total se queda en 4,00 € para siempre.
Pruébalo
Rellena el hueco
La cantidad se calcula a partir del carrito. ¿Qué va en el hueco?
const items = ref(START);
const count = (() => items.value.reduce((sum, i) => sum + i.qty, 0));computed: Vue ejecuta la función otra vez cuando items cambia y guarda el resultado entre renders.La idea
Cuándo guardar el resultado
computed ya guarda el resultado. Se calcula cuando lo lees por primera vez. Después, mientras los datos que leyó no cambien, devuelve el valor recordado. Así que dos sitios de la plantilla con {{ total }} calculan el total una sola vez.
const sorted = computed(() => products.value.toSorted(byPrice));Una función llamada en la plantilla ({{ totalOf(items) }}) también da el total correcto. La diferencia es que se ejecuta en cada render del componente, y computed solo cuando cambian sus dependencias. La función de computed solo lee y devuelve. No cambia el estado ni llama a fetch.
La idea
El filtro también se calcula
El catálogo tiene los botones «Todos», «Pan» y «Leche». La tentación es guardar dos arrays: todos los productos y los visibles. Pero los visibles se deducen de otros dos datos: la lista completa y la categoría elegida. Guardas solo la categoría:
const category = ref('all');const visible = computed(() => category.value === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category.value),);Con la ordenación pasa igual: guardas «por precio» o «por nombre», y la lista ordenada se calcula. Ojo con sort(): ordena el array en el sitio, es decir, cambia el estado. toSorted() devuelve un array nuevo y deja el viejo.
Pruébalo
Solo la categoría
Los botones cambian la categoría, pero la lista siempre muestra todo. Calcula los productos visibles a partir de la categoría y muestra «Mostrados: 3 de 5» encima de la lista. La comprobación elige «Leche», luego «Pan», luego «Todos».
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2, category: 'bread' },
{ id: 2, name: 'Pan integral', price: 2.1, category: 'bread' },
{ id: 5, name: 'Leche fresca', price: 1.6, category: 'milk' },
{ id: 6, name: 'Yogur', price: 0.9, category: 'milk' },
{ id: 7, name: 'Queso blanco', price: 7.9, category: 'milk' },
];
const CATEGORIES = [
{ id: 'all', name: 'Todos' },
{ id: 'bread', name: 'Pan' },
{ id: 'milk', name: 'Leche' },
];
const category = ref('all');
const visible = computed(() =>
category.value === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category.value),
);
</script>
<template>
<main>
<h1>Productos</h1>
<button v-for="c in CATEGORIES" :key="c.id" type="button" :aria-pressed="category === c.id" @click="category = c.id">
{{ c.name }}
</button>
<p>Mostrados: {{ visible.length }} de {{ PRODUCTS.length }}</p>
<ul>
<ProductCard v-for="p in visible" :key="p.id" :product="p" />
</ul>
</main>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product']);
</script>
<template>
<li>
<span>{{ product.name }}</span> {{ money(product.price) }}
</li>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
visible se calcula a partir de category y PRODUCTS: para «Todos» es la lista entera, y si no, un filter por categoría. La cantidad es visible.length de PRODUCTS.length.
Sin computed (const visible = PRODUCTS.filter(…)) el cálculo se ejecuta una vez y la lista no sigue a los botones.
La idea
Una copia de una prop
El mismo error ocurre entre componentes. App da un 20 % de descuento y pasa los precios nuevos a cada ProductCard. La tarjeta, sin embargo, recuerda el precio que recibió la primera vez:
Consola
Pulsa «20 % de descuento». El botón queda pulsado, y el pan sigue costando 1,20 €.
const price = ref(props.product.price) copia el número una vez, cuando se ejecuta <script setup>. Después props.product es nuevo, pero price es una caja aparte que nadie cambia.
La solución es no copiar: muestra product.price directamente. Si la tarjeta tiene que mostrar algo calculado a partir de una prop (el precio con IVA, el precio por unidad), calcúlalo durante el render con computed(() => props.product.price * …).
Pruébalo
Encuentra el error
La compradora pulsa «+»: la cantidad de la línea pasa a 3, y el total de la línea se queda como con 2. ¿Qué línea tiene la culpa?
Elige la línea con el error.
props.item.price * props.item.qty una vez en una ref. Debería ser const subtotal = computed(() => props.item.price * props.item.qty);.Más precisamente, para curiosos
Cuando la copia es a propósito. A veces quieres un valor de fuera solo como punto de partida: un formulario de edición empieza con el nombre actual, y luego la persona escribe. Entonces la copia es correcta, pero dilo en el nombre: la prop initialName, el estado name. Quien lee el código ve que no se siguen los cambios de fuera.
Un computed escribible. computed({ get, set }) da un valor al que también se puede asignar: set convierte el valor nuevo en un cambio del estado real. Se usa poco, por ejemplo para un campo ligado a una parte de un objeto.
computed es perezoso. Si nadie lee total, el cálculo no se ejecuta. Por eso dentro no hay efectos secundarios: no sabes cuándo ni cuántas veces se ejecutará. Para una acción cuando algo cambia existe watch, el módulo siguiente.
La Options API. En el estilo antiguo los valores calculados están en el objeto computed: { total() { … } }. La idea es la misma.
Lo mismo vale para los datos del servidor. Si el servidor devuelve los productos y guardas «número de productos» aparte, los dos pueden separarse después de una carga nueva. Guarda la respuesta y calcula la cantidad.
Ejercicio
Envío gratis
El carrito tiene botones «−» y «+» en cada línea («−» en la última unidad quita la línea). Debajo de la lista muestra «Artículos: 4», «Total: 18,20 €» y «Para el envío gratis faltan 11,80 €», y a partir de 30 € (FREE_FROM) solo «El envío es gratis». Todo se calcula a partir de items. La comprobación añade queso hasta 34,00 €, luego vuelve por debajo de 30 € y quita el pan.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const FREE_FROM = 30;
const items = ref([
{ id: 7, name: 'Queso blanco', price: 7.9, qty: 2 },
{ id: 1, name: 'Pan blanco', price: 1.2, qty: 2 },
]);
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));
const free = computed(() => total.value >= FREE_FROM);
function more(item) {
item.qty++;
}
function less(item) {
if (item.qty === 1) items.value = items.value.filter((i) => i.id !== item.id);
else item.qty--;
}
</script>
<template>
<main>
<h1>Tu carrito</h1>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Menos ${item.name}`" @click="less(item)">−</button>
<button type="button" :aria-label="`Más ${item.name}`" @click="more(item)">+</button>
</li>
</ul>
<p>Artículos: {{ count }}</p>
<p>Total: {{ money(total) }}</p>
<p v-if="free">El envío es gratis</p>
<p v-else>Para el envío gratis faltan {{ money(FREE_FROM - total) }}</p>
</main>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
La cantidad, el total y free se calculan a partir de items; el mensaje del envío es renderizado condicional: free o el importe restante FREE_FROM - total. La comprobación supera los 30 € y vuelve por debajo. Por eso se detecta un «free» guardado aparte: pasa a true con «+», pero nadie lo devuelve con «−».
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas y claves · Condiciones: vacío, error, cargando · Estado · Actualizar objetos y arrays
- Continúa en: Estado compartido: hacia arriba · Efectos · Estado compartido · Enrutamiento
- Ejercicios sobre el tema: Estado