State
In short: State is a value a component remembers between renders; in Vue it is kept with ref, and every change updates the component on the page.
Problem
The button does not count
The product card has an "Add" button and an "In the cart" counter. Press "Add" three times.
Console
The console says count = 1, 2, 3. The card says 0. There is no error. The variable count changes, but the screen does not follow it. Where is the difference?
The idea
A plain variable is not enough
The card is not drawn by your code but by the library. It draws again only when it knows that something changed. count++ changes the variable, but tells nobody.
<script setup> runs once for every card, and the template is run by Vue. Vue runs the template again only when something it watches changes. Vue does not watch a plain let variable. The click changes count, but the template does not run again, so the card stays at 0.
You need data that the component remembers between two renders and whose change draws it again. This is called state.
The idea
useState and ref
<script setup>import { ref } from 'vue';
const count = ref(0);
function add() { count.value++;}</script>
<template> <p>In the cart: {{ count }}</p></template>ref(0)returns a box, and the number is incount.value. In the script you write.value.- In the template Vue unwraps the box: you write
{{ count }}, without.value. - When the template reads
count, Vue remembers it. Whencount.valuechanges, Vue runs the template again.
The click handler is given as a function: @click="add". In Vue @click="add()" or @click="count++" also work: the template wraps them in a function that runs on the click.
Console
See it
See what happens on a click
<script setup>const count = ref(0);
function addThree() { count.value++; count.value++; count.value++;}</script>
<template> <article> <button @click="addThree">+3</button> <p>In the cart: {{ count }}</p> </article></template>Step by step, in words
- The card keeps count in a ref: a box whose value is count.value. Vue watches who reads it.
- Vue runs the template. It reads count and returns a description, a button and a text with 0. Vue remembers that the template reads count.
- The first time, the description becomes real elements in the DOM. The page now shows “In the cart: 0”.
- A click on +3: the browser calls addThree.
- count.value++ three times. The box changes at once: 1, 2, 3. Vue plans to run the template again, but only once.
- The handler is done. Vue runs the template once, with count = 3, and it returns a new description.
- Vue compares the new description with the old one. The button is the same, only the text changed. In the DOM only the text changes.
- The counter shows 3. A change of a ref is immediate, and the drawing happens once, after the handler.
The card is not built again. The library compares the new description with the old one and changes only the difference in the DOM: the text. The button is the same element and the focus stays on it. That is the difference from render() in the DOM module, which replaced everything.
Try it
Fix the counter
This is the card from the start. Make count state, so that after every click on "Add" the card shows the new number.
Console
Solution
ProductCard.vue<script setup>
import { ref } from 'vue';
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
const count = ref(0);
function add() {
count.value++;
}
</script>
<template>
<article class="card">
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<button type="button" @click="add" :aria-label="`Add ${product.name}`">
Add
</button>
<p>In the cart: {{ count }}</p>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const product = { id: 1, name: 'White bread', price: 1.2 };
</script>
<template>
<main>
<h1>The shop</h1>
<ProductCard :product="product" />
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 16px 12px; margin-bottom: 12px; max-width: 340px; }
.card h2 { font-size: 18px; margin: 8px 0 4px; }
.card p { margin: 6px 0; }
button { font: inherit; padding: 4px 12px; margin-right: 6px; }
let count = 0 becomes const count = ref(0), with import { ref } from 'vue', and in add you write count.value++. The template keeps {{ count }}: with {{ count.value }} the text is empty, because there count is already the number.The idea
The box and the number
Mineral water is also sold in a pack of 6 bottles. It is handy for the pack button to call add() six times. Here the two libraries behave differently.
count.value is always the current number, so count.value++ six times gives 6. The template does not run six times for that. Vue collects the changes and runs it once, after the handler.
The mistake here is a different one. The box is count, and count.value is a number. A number put into another variable is a copy:
let bottles = count.value;bottles += 6; // changes the copy, count stays the sameVue watches the box, not every variable the number has been in. Write into the box: count.value += 6.
Try it
Find the bug
"Add a pack" should add 6 bottles. Which line is to blame?
Pick the line with the bug.
Line 5: it changes bottles, a copy of the number. The box count is not touched and the template does not run. It should be count.value += 6.
Try it
A pack of water
Write addBox() so that "Add a pack (6 pcs)" adds 6 bottles to what is already in the cart. The check presses "Add" once, then the pack twice, and expects 1, 7 and 13.
Console
Solution
ProductCard.vue<script setup>
import { ref } from 'vue';
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
const count = ref(0);
function add() {
count.value++;
}
function addBox() {
count.value += 6;
}
</script>
<template>
<article class="card">
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<button type="button" @click="add" :aria-label="`Add ${product.name}`">
Add
</button>
<button type="button" @click="addBox">Add a pack (6 pcs)</button>
<p>In the cart: {{ count }}</p>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
const product = { id: 15, name: 'Mineral water', price: 0.9 };
</script>
<template>
<main>
<h1>The shop</h1>
<ProductCard :product="product" />
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 16px 12px; margin-bottom: 12px; max-width: 340px; }
.card h2 { font-size: 18px; margin: 8px 0 4px; }
.card p { margin: 6px 0; }
button { font: inherit; padding: 4px 12px; margin-right: 6px; }
count.value += 6 changes the box and the template runs again. let n = count.value; n += 6 changes a copy and the card does not move. count.value = 6 does not add, it sets 6, and after "Add" the counter is 6, not 7.The idea
Each card remembers its own
State belongs to the instance, not to the code. The catalogue has two cards from the same ProductCard, and each has its own count:
Console
Press "Add" on the bread twice and on the milk once: 2 and 1. The library keeps the state of each card by its place in the tree of components. Remove the card from the page and its state goes with it. When two cards must show the same number, it has to live in their common parent. That is the lesson on shared state in this module.
More precisely, if you are curious
ref and reactive. reactive({ count: 0 }) makes the object watched and has no .value, but it works only with objects and the whole object cannot be replaced. ref works with any value, so the Vue documentation recommends it as the main one.
When the template runs. Vue runs the template again in the next microtask, after the code running now is done. await nextTick() waits for that render, for example to read the new DOM.
Unwrapping. In the template, refs at the top level of <script setup> are unwrapped. A ref inside a plain object ({ total: ref(0) }) is not unwrapped in an expression like {{ cart.total + 1 }}.
Options API. Older Vue code writes the component as an object with data(), methods and this.count. New code uses <script setup> and the Composition API.
Exercise
Choose the quantity
The catalogue has three cards. In each one add a quantity that starts at 1, a "−" button (name for the screen reader: "Fewer White bread"), a "+" button ("More White bread") and a line "2 × €1.20 = €2.40" with the quantity, the price and the total. "−" is disabled when the quantity is 1. Each card counts on its own.
Console
Solution
ProductCard.vue<script setup>
import { ref } from 'vue';
defineProps({ product: Object });
const money = (x) => '€' + x.toFixed(2);
const count = ref(1);
function less() {
count.value--;
}
function more() {
count.value++;
}
</script>
<template>
<article class="card">
<h2>{{ product.name }}</h2>
<p>{{ money(product.price) }}</p>
<p>
<button type="button" @click="less" :disabled="count === 1" :aria-label="`Fewer ${product.name}`">−</button>
<button type="button" @click="more" :aria-label="`More ${product.name}`">+</button>
</p>
<p>{{ count }} × {{ money(product.price) }} = {{ money(count * product.price) }}</p>
</article>
</template>
App.vue<script setup>
import ProductCard from './ProductCard.vue';
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 },
];
</script>
<template>
<main>
<h1>The shop</h1>
<ProductCard v-for="p in products" :key="p.id" :product="p" />
</main>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
.card { border: 1px solid #ddd5c4; border-radius: 8px; padding: 4px 16px 12px; margin-bottom: 12px; max-width: 340px; }
.card h2 { font-size: 18px; margin: 8px 0 4px; }
.card p { margin: 6px 0; }
button { font: inherit; padding: 4px 12px; margin-right: 6px; }
ProductCard.vue: const count = ref(1), less does count.value--, and more does count.value++. "−" is :disabled="count === 1": that way the quantity cannot drop below 1. The total is money(count * product.price) right in the template: it follows from count and is not separate state, which is what the lesson on derived state is about. If the ref is in a separate plain <script>, it is one for all cards, and "+" on the bread changes the cheese too.Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Events · The first component · Props: data from above · Lists and keys
- Continues in: Updating objects and arrays · Derived state · Lifting state up · Effects
- Tasks on this topic: State