Updating objects and arrays
In short: An object or array in Vue state is changed in place, because Vue watches changes through a proxy; a new array in .value works too, but a plain array without ref does not.
Problem
"Remove" does not remove
The cart has three lines, each with a "Remove" button. Press "Remove" on the bread.
Console
The console says lines: 2. The screen shows all three lines and the old total. The data changed, the screen does not follow it, and there is no error.
remove puts a new array without the bread into cart. The new value is in the variable. Why does Vue not draw?The idea
How the library sees a change
With a number it is clear: 1 and 2 are different. An array and an object, though, are references: the variable points to an object in memory. There are two questions. Does the variable point to another object? And did the contents of the same object change? Each library asks in its own way, and here the difference shows in the code.
Vue asks the second question. ref([...]) wraps the array in a proxy: an object that stands in front of the array and sees every read and write. push, splice and line.qty++ go through the proxy. It records the change and tells the template that reads the array to run again. That is why in Vue the array is changed in place, and that is the normal way.
The proxy does not see the variable that points to it, though. In the example let cart = reactive([...]) and then cart = cart.filter(...): the variable now points to a new, plain array. The template reads the old array through the old proxy, and nobody tells it. With const cart = ref([...]) the new array goes into cart.value, and the ref sees that change.
See it
See: the same array or a new one
<script setup>const cart = ref([bread]);
function addMilk() { cart.value.push(milk);}
function addCheese() { cart.value = [...cart.value, cheese];}</script>Step by step, in words
- cart is a ref. Inside it is the array, wrapped in a proxy: an object that stands in front of the array and sees every read and write.
- cart.value.push(milk) goes through the proxy. The proxy records: the array changed.
- The proxy tells the template, which reads cart, that it has to run again.
- The template runs and the screen shows both lines.
- This works too: a new array in cart.value. The ref notices the change of .value.
- The ref tells the template, and the template runs again with the new array.
- The screen shows three lines. In Vue both ways work.
- The rule in Vue: change things through the ref. Vue does not see a plain variable or a copy of a number.
push through the array's proxy, and the new array through .value. Vue misses only what goes around both.The idea
A change through the proxy
The cart has three actions: add a line, change the quantity of one line, remove a line.
// addcart.value.push({ ...product, qty: 1 });
// change one line: the line is a proxy tooconst line = cart.value.find((l) => l.id === id);line.qty++;
// removeconst index = cart.value.findIndex((l) => l.id === id);cart.value.splice(index, 1);- Vue watches the objects inside the array too:
linefromcart.valueis a proxy andline.qty++is seen. - The other way works too: a new array in
cart.value, for examplecart.value = cart.value.filter((l) => l.id !== id). The ref sees the change of.value. - A change that goes around the ref is not seen: a plain variable with a new array, or a copy of a number.
Try it
Fix "Remove"
This is the cart from the start. Fix remove so that the line disappears and the total is computed again. The check removes the bread, then the cheese.
Console
Solution
App.vue<script setup>
import { ref } from 'vue';
const money = (x) => '€' + x.toFixed(2);
const cart = ref([
{ id: 1, name: 'White bread', price: 1.2, qty: 2 },
{ id: 5, name: 'Fresh milk', price: 1.6, qty: 1 },
{ id: 7, name: 'White cheese', price: 7.9, qty: 1 },
]);
function remove(id) {
cart.value = cart.value.filter((line) => line.id !== id);
}
const total = () => cart.value.reduce((sum, line) => sum + line.price * line.qty, 0);
</script>
<template>
<main>
<h2>The cart</h2>
<ul>
<li v-for="line in cart" :key="line.id">
{{ line.name }}: {{ line.qty }} × {{ money(line.price) }}
<button type="button" @click="remove(line.id)" :aria-label="`Remove ${line.name}`">
Remove
</button>
</li>
</ul>
<p>Total: {{ money(total()) }}</p>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
let cart = reactive(...) becomes const cart = ref(...) with import { ref } from 'vue', and remove is cart.value = cart.value.filter((line) => line.id !== id). splice through cart.value works too. The total reads cart.value, so it runs again as well. A plain array without ref does not work: splice changes it, but Vue does not watch it.Try it
Find the bug
"+" does not raise the quantity. Which line is to blame?
Pick the line with the bug.
Line 2: let { qty } = line copies the number into a new variable. qty++ changes the copy, and the line in the cart stays the same. It should be line.qty++.
The idea
One line of the array
The customer presses "+" on the bread. The qty of one object has to change, and the object is in an array.
In Vue line.qty++ is the right way: the line from cart.value is a proxy and Vue sees the change. The mistake is to take the number out of the object:
let { qty } = line; // a copy of the numberqty++; // changes the copyDestructuring and let n = line.qty give a copy. Vue watches the object line, not every variable the number has been in. Write into the object: line.qty++.
More precisely, if you are curious
Deep watching. ref and reactive make the objects inside watched too, when they are first read. shallowRef watches only .value: with it a change inside is not seen, and you put in a new array, as in React.
reactive and destructuring. const { total } = reactive({ total: 0 }) gives a number, not a watched value. toRefs(obj) turns the fields into refs that stay connected to the object.
The new array. cart.value = [...cart.value, line] works and makes a new array, as you would in React. It is useful when the old array must stay the same, for example for "Undo".
structuredClone does not accept a proxy: structuredClone(cart.value) throws an error. First take the plain array with toRaw(cart.value).
Try it
"+" and "−"
Write more and less. "+" raises the quantity of the line by 1, and "−" lowers it by 1. When the quantity is 1, "−" removes the line. The total follows the quantity.
Console
Solution
App.vue<script setup>
import { ref } from 'vue';
const money = (x) => '€' + x.toFixed(2);
const cart = ref([
{ id: 1, name: 'White bread', price: 1.2, qty: 2 },
{ id: 5, name: 'Fresh milk', price: 1.6, qty: 1 },
{ id: 7, name: 'White cheese', price: 7.9, qty: 1 },
]);
function more(id) {
const line = cart.value.find((l) => l.id === id);
line.qty++;
}
function less(id) {
const line = cart.value.find((l) => l.id === id);
if (line.qty === 1) {
remove(id);
} else {
line.qty--;
}
}
function remove(id) {
const index = cart.value.findIndex((line) => line.id === id);
cart.value.splice(index, 1);
}
const total = () => cart.value.reduce((sum, line) => sum + line.price * line.qty, 0);
</script>
<template>
<main>
<h2>The cart</h2>
<ul>
<li v-for="line in cart" :key="line.id">
{{ line.name }}: {{ line.qty }} × {{ money(line.price) }}
<button type="button" @click="less(line.id)" :aria-label="`Fewer ${line.name}`">−</button>
<button type="button" @click="more(line.id)" :aria-label="`More ${line.name}`">+</button>
<button type="button" @click="remove(line.id)" :aria-label="`Remove ${line.name}`">
Remove
</button>
</li>
</ul>
<p>Total: {{ money(total()) }}</p>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
more finds the line in cart.value and does line.qty++. less calls remove when qty === 1, otherwise it does line.qty--. With let { qty } = line and qty++ the line stays the same: a copy changes. Without the check for 1 the line reaches "0 ×" and stays in the cart.
Exercise
Catalogue and cart
The catalogue has four products with an "Add" button (the name for the screen reader: "Add White bread"). Write add and remove. The first click on "Add" makes a line with quantity 1, and the next ones raise the quantity of the same line instead of making a new one. "Remove" removes the line. When the cart is empty, it says "The cart is empty".
Console
Solution
App.vue<script setup>
import { ref } from 'vue';
const money = (x) => '€' + x.toFixed(2);
const products = [
{ id: 1, name: 'White bread', price: 1.2 },
{ id: 5, name: 'Fresh milk', price: 1.6 },
{ id: 7, name: 'White cheese', price: 7.9 },
{ id: 10, name: 'Apples', price: 2.1 },
];
const cart = ref([]);
function add(product) {
const found = cart.value.find((line) => line.id === product.id);
if (found) {
found.qty++;
} else {
cart.value.push({ ...product, qty: 1 });
}
}
function remove(id) {
const index = cart.value.findIndex((line) => line.id === id);
cart.value.splice(index, 1);
}
const total = () => cart.value.reduce((sum, line) => sum + line.price * line.qty, 0);
</script>
<template>
<main>
<h1>The shop</h1>
<ul>
<li v-for="p in products" :key="p.id">
{{ p.name }}, {{ money(p.price) }}
<button type="button" @click="add(p)" :aria-label="`Add ${p.name}`">
Add
</button>
</li>
</ul>
<h2>The cart</h2>
<p v-if="cart.length === 0">The cart is empty</p>
<ul aria-label="The cart">
<li v-for="line in cart" :key="line.id">
{{ line.name }}: {{ line.qty }} × {{ money(line.price) }}
<button type="button" @click="remove(line.id)" :aria-label="`Remove ${line.name}`">
Remove
</button>
</li>
</ul>
<p>Total: {{ money(total()) }}</p>
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
add looks for the line with find. If it is there: found.qty++. If not: cart.value.push({ ...product, qty: 1 }). { ...product, qty: 1 } is a new object, and the product in the catalogue is not touched. remove is splice or filter through cart.value. With const cart = [] without ref the cart stays empty on the screen, and with only push every click adds a new line for the same product.Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The first component · Lists and keys · Conditions: empty, error, loading · State
- Continues in: Derived state · Lifting state up · Shared state · The shop front
- Tasks on this topic: State