Lifting state up
In short: Lifting state up moves shared state to the closest common parent: data come down as props, and changes go up as an event (emit).
Problem
The badge at the top does not know
The shop has a cart badge at the top and a list of products below. Add bread, milk and one more bread.
Console
The bread card says "In the cart: 2", and the top says "Cart (0)". The cart lives in ProductList: that is where the button is pressed and where the state was added. CartBadge is another component, a sibling of ProductList, and it has its own number that nobody changes. The two components have two carts, and each is right about its own.
The idea
Up to the common parent
A component sees only its own state and the props its parent passes to it. A sibling cannot see its sibling. So two places that show one cart cannot each have their own.
The solution is to move the state to the closest common parent of the two. Here that is App. This is called lifting state up:
Appkeepsitemsand computescount.- Data come down as props:
counttoCartBadge,itemstoProductList. - Changes go up as a message: the button in
ProductCarddoes not change the cart, it tellsApp"add this product".
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));function add(product) { /* changes items */ }</script>
<template> <header><CartBadge :count="count" /></header> <main><ProductList :products="PRODUCTS" :items="items" @add="add" /></main></template>The child declares which events it sends with defineEmits(['add']) and sends them with emit('add', product). The parent listens with @add="add", the same way it listens to @click on a button.
Now CartBadge and ProductList have no state of their own for the cart. They show what App gives them.
See it
See it move up
At first the cart is in ProductList. Then it moves to App and flows down from there.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0),);function add(product) { plusOne(items.value, product);}</script><template> <CartBadge :count="count" /> <ProductList :items="items" @add="add" /></template>
<!-- ProductList.vue --><ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" :qty="qtyOf(items, p)" @add="(product) => emit('add', product)" />
<!-- ProductCard.vue --><button @click="emit('add', product)">…</button>Step by step, in words
- CartBadge and ProductList are siblings: both are children of App. At first the cart lives in ProductList, and CartBadge has its own count.
- A click on "Add" reaches ProductList, and the list counts 1. CartBadge cannot see that state: a sibling cannot read another sibling. It stays at 0.
- The fix: move the cart to the closest common parent of the two, App. The badge drops its own copy.
- From App the data flow down as props: count to CartBadge, items to ProductList and qty to every ProductCard. Both siblings now read one cart.
- The child does not own the cart, so it cannot change it. The click emits the event add with the product. ProductList passes it on, and App listens with @add and calls add().
- App changes items, and the new props flow down: the badge says 2, the card says 2. Nobody updated them by hand.
- The rule: the state lives in one place, as high as needed and no higher. Data go down as props, changes go up as events.
The direction is the same in every app: data down, messages up. Whoever owns the data is the only one who changes them.
Try it
Move the cart up
The cart is in ProductList, and CartBadge counts a number of its own. Move the cart to App: CartBadge gets the count as a prop, ProductList gets items and reports "Add" up. The check adds bread, milk and bread and looks at the cards and the badge.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.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 },
];
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 });
}
</script>
<template>
<header>
<a href="/">The shop</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Products</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Cart ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">In the cart: {{ qty }}</span>
<button type="button" :aria-label="`Add ${product.name}`" @click="emit('add', product)">
Add
</button>
</li>
</template>
money.jsexport const money = (x) => '€' + x.toFixed(2);
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; }
items, add and count move to App.vue. CartBadge gets :count="count". ProductList gets :items="items" and sends add up: defineEmits(['add']) and @add="(product) => emit('add', product)" on every ProductCard. There is no ref in ProductList any more.
If App does not write @add="add", the event is sent, but nobody listens. Nothing happens, and the console has no error. The check catches it by the behaviour.
The idea
The child reports, the parent decides
ProductCard does not know how the cart is built. It only knows that someone pressed "Add" on its product. So it reports what happened, not what to change: "add this product", not "increase qty on line 2". App decides what that means: a new line or one more unit.
This keeps the card simpler, and it can sit anywhere: in the catalogue, in "Recommended", in the search. It does not own the data and does not touch them.
The event is a verb without on: add, remove, change. The child declares it in defineEmits(['add']), sends it with emit('add', product), and the parent listens with @add. The second argument of emit is what the parent's function receives.
Try it
Fill in
What goes in the blank so that the click reaches the parent?
<script setup>
defineProps(['product']);
const emit = defineEmits(['add']);
</script>
<template>
<button @click="('add', product)">Add</button>
</template>emit: the function from defineEmits. It sends the event add with the product, and the parent listens with @add.Try it
Find the bug
Pick the line with the bug.
App listens to @added, and the card sends add. The names have to match: @add="add".The idea
When the tree gets deep
How far up? As high as needed, and no higher. If only ProductCard uses something (whether the description is open), it stays in it. You move it up when a second component has to see it.
In a real shop the cart is needed in many places: the badge at the top, the list, the product page, the order. The common parent is high up, and the way to them passes through components that do not use the cart, they only pass it down. This is called prop drilling. With two levels it is normal. With five levels every component on the way has to know about data that are not its own.
There are separate tools for this: provide/inject and Pinia. They give the data to distant descendants without the components on the way. They are in the lesson on shared state in the module "Effects, data and forms". The rule stays the same: the data have one owner, and changes go through it.
More precisely, if you are curious
Props are read-only. props.qty = 3 in Vue changes nothing and writes [Vue warn] Set operation on key "qty" failed: target is readonly to the console. A child that wants a change reports up.
v-model on a component is the same thing. <QtyInput v-model="qty" /> is short for :modelValue="qty" and @update:modelValue="(v) => qty = v". The data still come down as a prop and still go up as an event.
Why defineEmits. Declared events are documentation: you see what the component says. And Vue checks them: if the card sends emit('added') and has declared only add, the console says Component emitted event "added" but it is neither declared in the emits option….
ref(props.items) in the child does not copy the array: it is the same reactive array of the parent, and whatever is added shows up. But items.value = items.value.filter(…) changes only the child's box. The removal never reaches the parent, and the two carts drift apart.Exercise
The cart on the side
Next to the list there is a third part, CartSummary: the heading "The cart", lines like "White bread × 2" with a "Remove" button, "Total: €4.00" and "The cart is empty" when it is empty. Right now CartSummary keeps a cart of its own that nobody fills. Give it the cart from App and make "Remove" remove from it. The check adds three products, removes them and looks at the badge, the cards and the total.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.vue';
import CartSummary from './CartSummary.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 },
];
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 });
}
function remove(item) {
items.value = items.value.filter((i) => i.id !== item.id);
}
</script>
<template>
<header>
<a href="/">The shop</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Products</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
<CartSummary :items="items" @remove="remove" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Cart ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">In the cart: {{ qty }}</span>
<button type="button" :aria-label="`Add ${product.name}`" @click="emit('add', product)">
Add
</button>
</li>
</template>
CartSummary.vue<script setup>
import { computed } from 'vue';
import { money } from './money.js';
const props = defineProps(['items']);
const emit = defineEmits(['remove']);
const total = computed(() => props.items.reduce((sum, i) => sum + i.price * i.qty, 0));
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">The cart</h2>
<p v-if="items.length === 0">The cart is empty</p>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Remove ${item.name}`" @click="emit('remove', item)">
Remove
</button>
</li>
</ul>
<p>Total: {{ money(total) }}</p>
</section>
</template>
money.jsexport const money = (x) => '€' + x.toFixed(2);
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; }
App.vue gets a function remove(item) that filters items. CartSummary gets :items="items", declares defineEmits(['remove']), and the button calls emit('remove', item). App listens with @remove="remove". Now the badge, the cards and the cart on the side read one state, so removing shows in all three places.
ref(props.items) in CartSummary with removal in place: the added products show up, because the array is the same, but items.value = …filter(…) changes only the box of CartSummary. The badge at the top stays at 3.Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Conditions: empty, error, loading · State · Updating objects and arrays · Derived state
- Continues in: Shared state · Routing · Speed: when a component renders again · Testing components
- Tasks on this topic: State