Derived state
In short: Derived state is a value computed from the state (a count, a total, a filtered list) instead of being stored separately; computed keeps the result until the next change.
Problem
The total falls behind
The cart has white bread and fresh milk. Press "+" on the bread: the count and the total grow. Now remove the milk.
Console
The line disappears, the count drops to 3, and the total stays €5.20. The component keeps three states: items, count and total. Every click has to change all three, and the remove function changes two. This is the same drift as in the lesson "A list from data", only inside a component. The library draws exactly what you give it. You give it two truths about one cart.
The idea
Compute it, do not keep it
Ask about every piece of state: can I compute it from the other state or from the props? If you can, it is not state. The count is the sum of the quantities in items. The total is the sum of price times quantity. Both are computed.
Such a value is called derived state. You keep only items and compute the rest from them every time the component renders.
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> runs once, when the component is created. That is why the calculation is in a function given to computed. Vue remembers that it reads items and runs it again when items change. In the template you write {{ total }}, without .value.
Now the functions more and remove change only items. There is nothing left for them to forget.
See it
See the computed values and the copy
On the left are the cart and a copy of the total, kept separately. On the right are the three computed values.
<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>Step by step, in words
- On the left is the state: the cart items. Below it is a second state, savedTotal: the same total, kept as a copy.
- On the right are count, total and free: computed values. Vue remembers that they read items and computes them again when items change: €26.90, €3.10 left to free delivery.
- more(cheese): items change and the three arrows light up. The total is €34.80, delivery is free. more() also updates savedTotal by hand, so the copy is still right.
- remove(cheese): items change, and count, total and free follow by themselves: €3.20. Nobody wrote a line for savedTotal in remove().
- The copy says €34.80, the cart is €3.20. No error, only a wrong number: the state and its copy have drifted apart.
- The rule: keep only what cannot be computed. Whatever follows from the state is computed from it, so there is no copy to forget.
The computed values follow the cart on every change, because they have no memory of their own. The copy follows only as long as somebody remembers to update it. The first function that forgets makes it wrong.
Try it
Compute the count and the total
The cart keeps count and total as separate states, and remove() forgets the total. Remove both states and compute the count and the total from items. The check presses "+" on the bread, then removes the milk and looks at the count and the total.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const items = ref([
{ id: 1, name: 'White bread', price: 1.2, qty: 2 },
{ id: 5, name: 'Fresh milk', price: 1.6, qty: 1 },
]);
// Count and total: computed on every 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>The cart</h1>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`One more ${item.name}`" @click="more(item)">+</button>
<button type="button" :aria-label="`Remove ${item.name}`" @click="remove(item)">Remove</button>
</li>
</ul>
<p>Items: {{ count }}</p>
<p>Total: {{ money(total) }}</p>
</main>
</template>
money.jsexport const money = (x) => '€' + x.toFixed(2);
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; }
Only items is left. The count is items.reduce((sum, i) => sum + i.qty, 0), the total is the same with i.price * i.qty. In Vue they are computed: they are computed again when items change. more and remove now change only the cart.
A trap: const total = items.value.reduce(…) without computed. <script setup> runs once, so the total stays €4.00 forever.
Try it
Fill in
The count is computed from the cart. What goes in the blank?
const items = ref(START);
const count = (() => items.value.reduce((sum, i) => sum + i.qty, 0));computed: Vue runs the function again when items change and keeps the result between renders.The idea
When to keep the result
computed already keeps the result. It is computed when you first read it. After that, as long as the data it read do not change, it returns the remembered value. So two places in the template with {{ total }} compute the total once.
const sorted = computed(() => products.value.toSorted(byPrice));A function called in the template ({{ totalOf(items) }}) also gives the right total. The difference is that it runs on every render of the component, and computed only when its dependencies change. The function in computed only reads and returns. It does not change state and does not call fetch.
The idea
The filter is computed too
The catalogue has the buttons "All", "Bread" and "Milk". The temptation is to keep two arrays: all products and the visible ones. But the visible ones follow from two other facts: the full list and the chosen category. You keep only the category:
const category = ref('all');const visible = computed(() => category.value === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category.value),);Sorting is the same: you keep "by price" or "by name", and the sorted list is computed. Watch out for sort(): it sorts the array in place, which means it changes the state. toSorted() returns a new array and leaves the old one.
Try it
Only the category
The buttons change the category, but the list always shows everything. Compute the visible products from the category and show "Showing 3 of 5" above the list. The check picks "Milk", then "Bread", then "All".
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'White bread', price: 1.2, category: 'bread' },
{ id: 2, name: 'Wholegrain bread', price: 2.1, category: 'bread' },
{ id: 5, name: 'Fresh milk', price: 1.6, category: 'milk' },
{ id: 6, name: 'Yoghurt', price: 0.9, category: 'milk' },
{ id: 7, name: 'White cheese', price: 7.9, category: 'milk' },
];
const CATEGORIES = [
{ id: 'all', name: 'All' },
{ id: 'bread', name: 'Bread' },
{ id: 'milk', name: 'Milk' },
];
const category = ref('all');
const visible = computed(() =>
category.value === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category.value),
);
</script>
<template>
<main>
<h1>Products</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>Showing {{ visible.length }} of {{ 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);
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 is computed from category and PRODUCTS: for "All" it is the whole list, otherwise a filter by category. The count is visible.length of PRODUCTS.length.
Without computed (const visible = PRODUCTS.filter(…)) the calculation runs once and the list does not follow the buttons.
The idea
A copy of a prop
The same mistake happens between components. App gives 20% off and passes the new prices to every ProductCard. The card, however, remembers the price it got the first time:
Console
Press "20% off". The button is pressed, and the bread is still €1.20.
const price = ref(props.product.price) copies the number once, when <script setup> runs. After that props.product is new, but price is a separate box that nobody changes.
The fix is not to copy: show product.price directly. If the card has to show something computed from a prop (the price with VAT, the price per unit), compute it during render with computed(() => props.product.price * …).
Try it
Find the bug
The buyer presses "+": the quantity on the line becomes 3, and the line total stays as for 2. Which line is to blame?
Pick the line with the bug.
props.item.price * props.item.qty into a ref once. It should be const subtotal = computed(() => props.item.price * props.item.qty);.More precisely, if you are curious
When the copy is on purpose. Sometimes you want a value from outside only as a start: an edit form starts with the current name, and then the person types. Then the copy is right, but say so in the name: the prop initialName, the state name. Whoever reads the code sees that changes from outside are not followed.
A writable computed. computed({ get, set }) gives a value that can also be assigned: set turns the new value into a change of the real state. It is rarely used, for example for a field bound to part of an object.
computed is lazy. If nobody reads total, the calculation does not run. That is why there are no side effects inside: you do not know when and how many times it will run. For an action on change there is watch, the next module.
The Options API. In the older style the computed values are in the object computed: { total() { … } }. The idea is the same.
The same holds for data from the server. If the server returns the products and you keep "number of products" separately, the two can drift apart after a new load. Keep the response and compute the count.
Exercise
Free delivery
The cart has "−" and "+" buttons on every line ("−" on the last unit removes the line). Below the list show "Items: 4", "Total: €18.20" and "Free delivery in another €11.80", and from €30 (FREE_FROM) only "Delivery is free". Everything is computed from items. The check adds cheese up to €34.00, then goes back under €30 and removes the bread.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const FREE_FROM = 30;
const items = ref([
{ id: 7, name: 'White cheese', price: 7.9, qty: 2 },
{ id: 1, name: 'White bread', 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>The cart</h1>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`One less ${item.name}`" @click="less(item)">−</button>
<button type="button" :aria-label="`One more ${item.name}`" @click="more(item)">+</button>
</li>
</ul>
<p>Items: {{ count }}</p>
<p>Total: {{ money(total) }}</p>
<p v-if="free">Delivery is free</p>
<p v-else>Free delivery in another {{ money(FREE_FROM - total) }}</p>
</main>
</template>
money.jsexport const money = (x) => '€' + x.toFixed(2);
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; }
The count, the total and free are computed from items; the delivery message is conditional rendering: free or the remaining amount FREE_FROM - total. The check goes above €30 and back under it. That is why "free" kept as a separate state is caught: it becomes true on "+", but nobody sets it back on "−".
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Lists and keys · Conditions: empty, error, loading · State · Updating objects and arrays
- Continues in: Lifting state up · Effects · Shared state · Routing
- Tasks on this topic: State