Shared state
In short: Shared state in Vue comes from provide/inject or a Pinia store, and components read it directly, without props through every level.
Problem
The badge says 0 again
The cart is lifted into App, as in the lesson about lifting state up. From there it goes down as props: to Header and from it to CartBadge, to Catalog, from it to ProductList and from it to every ProductCard. Someone rewrote Header, and the badge stopped.
Console
Add bread, milk and bread again. The cards count, and the header says "Cart (0)". App passes count to Header, but the new Header does not pass it to CartBadge. Header does not use the cart. It only had to pass it on, and that is exactly what it forgot.
The idea
A provider at the top, consumers below
Passing through components that do not use the data is called prop drilling. With two levels it is normal. With five, every component on the way depends on the cart, and one missed prop breaks the chain.
Both libraries have a way to give data to distant descendants without the components in the middle:
- The provider is the component that owns the data. It says: "everything under me can read this".
- The consumer is the component that uses them. It takes them directly, no matter how many levels are in between.
In Vue these are provide and inject:
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));provide('cart', { items, count, add });</script>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script>provide('cart', …) gives a value under a key to all descendants of the component. inject('cart') takes it from the closest ancestor that provided it. The refs are passed (items, count), not their values, so the consumers stay reactive.
The data still have one owner, and the changes still go through it: add is the provider's function. Only the way changed.
See it
See the two ways
At first the cart goes down level by level, and the chain breaks in the middle. Then App becomes a provider and both consumers read directly.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0));function add(product) { /* … */ }provide('cart', { items, count, add });</script><template> <Header /> <Catalog /></template>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script><template> <a href="/cart">Cart ({{ count }})</a></template>
<!-- ProductCard.vue --><script setup>const { add } = inject('cart');</script><template> <button @click="add(product)">Add</button></template>Step by step, in words
- The cart lives in App. Two components far below need it: CartBadge in the header and ProductCard in the catalog.
- With props only, the cart goes down level by level. Header, Catalog and ProductList do not use it. They only pass it on.
- Someone rewrites Header and forgets count. The chain breaks in the middle, and the badge shows 0. That is prop drilling.
- Instead: App calls provide('cart', …). Every descendant of App can read the cart.
- CartBadge and ProductCard read it with inject('cart'). The components in between know nothing about the cart.
- A click on “Add” calls add(product). The function came from the provider, so the change happens in App.
- The cart in App changes, and every component that reads it draws again: the badge says 1, the card says 1.
- The data still have one owner, App. Only the way changed: not through every level, but straight from the provider.
The components in the middle know nothing about the cart now. The new Header can be rewritten as much as you like.
Try it
Remove the drilling
The cart goes down as props through Header and Catalog, and Header drops count. Give the cart from the top. Header, Catalog and ProductList get nothing about the cart, and CartBadge and ProductCard read it directly. The check adds bread, milk and bread and looks at the cards and the badge.
Console
Solution
App.vue<script setup>
import { ref, computed, provide } from 'vue';
import Header from './Header.vue';
import Catalog from './Catalog.vue';
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 });
}
provide('cart', { items, count, add });
</script>
<template>
<Header />
<main>
<Catalog />
</main>
</template>
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">The shop</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { inject } from 'vue';
const { count } = inject('cart');
</script>
<template>
<a href="/cart">Cart ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Products</h1>
<ProductList />
</section>
</template>
ProductList.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>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed, inject } from 'vue';
import { money } from './money.js';
const props = defineProps(['product']);
const { items, add } = inject('cart');
const qty = computed(() => items.value.find((i) => i.id === props.product.id)?.qty ?? 0);
</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="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; }
In App.vue call provide('cart', { items, count, add }) and remove the props and @add from Header and Catalog. CartBadge reads const { count } = inject('cart'). ProductCard reads items and add and works out the count with computed. Catalog and ProductList no longer have defineProps and defineEmits for the cart.
The check catches provide('cart', { count: count.value, … }): the number at that moment is passed, not the ref, and the badge stays at 0. It also catches qty without computed in ProductCard: setup runs once, so the number does not change.
The check sees only the behaviour. If you pass count through Header once more, it will pass. The goal here is different: the middle should not know about the cart.
The idea
A store for the cart
The cart grows: add, remove, change the count, empty it. If every action is a function in App, App fills up with logic that is not its job. So the shared state moves into a separate file, a store: the data and everything that changes them, in one place.
For stores Vue uses Pinia, the official library for shared state. A store is made with defineStore and written like <script setup>: a ref is the state, a computed is derived from it, the functions are the actions.
export const useCartStore = defineStore('cart', () => { const items = ref([]); const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0)); function add(product) { /* … */ } return { items, count, add };});Pinia is installed once, in main.js: createApp(App).use(createPinia()).mount('#app'). Every component calls useCartStore() and gets the same store. No provide in App is needed.
<script setup>import { storeToRefs } from 'pinia';const cart = useCartStore();const { count } = storeToRefs(cart);</script>The store is a reactive object: in the template you write cart.count, without .value. If you want to take it apart into variables, use storeToRefs(cart). It gives refs that stay connected to the store. The actions (cart.add) can be taken directly.
Console
cart.js: all the logic of the cart is there. App.vue has no state any more, and main.js installs Pinia.Try it
Find the bug
Pick the line with the bug.
const { count } = useCartStore() takes the number at that moment and cuts it off from the store. It should be const { count } = storeToRefs(useCartStore()), or cart.count in the template.The idea
When shared and when not
Shared state is handy, and that is why it is tempting to put everything there. But anything in it can be read and changed from anywhere, and that makes bugs harder to find. The rule from the lesson about lifting holds: as high as needed, and no higher.
- Shared: what distant parts of the tree need and what lives while the user is on the site. The cart, the logged-in user, the language, the theme.
- In the component: the fields of a form, whether the menu is open, which tab is chosen, the text in the search box. One component (or its parent) needs them, and they go away with it.
- The server's data are not shared state. The product list and the orders have an owner, and the owner is the server. In a store they are a copy that must be refreshed. The component that shows them loads them, as in the lesson about data from an API.
Shared state does not change the direction: the data start from one owner, and the changes go through its functions.
Try it
Fill in
CartBadge take count from the store so that it stays connected to it?<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = (useCartStore());
</script>storeToRefs: it gives a ref for every piece of state and every computed field of the store. A destructured ref stays connected, a destructured number does not.More precisely, if you are curious
A store with options. defineStore also takes an object: state: () => ({ items: [] }), getters and actions. It is the same thing written as the Options API. Pinia supports both kinds.
provide/inject or Pinia. provide is built in and is good for things one subtree needs (the settings of one table, a form with nested fields). Pinia is for the state of the whole application: it is visible in Vue DevTools, it is tested on its own and it does not depend on which component is at the top.
A symbol as the key. Instead of the string 'cart', provide often uses a Symbol() exported from a shared file. Then two different providers cannot have the same key by accident.
inject with a default. inject('cart', null) does not write a warning when nobody provided the key, and returns null.
The store lives while the page is open. On a reload it is lost. A cart that must survive is saved in localStorage or on the server.
The checks see behaviour. The tasks here cannot see whether the cart comes from props, from a context or from a store. They check that the badge and the cards show the same thing.
Exercise
The cart page
In the main part there is CartSummary: a heading The cart, rows White bread × 2 with a Remove button, Total: €4.00, and The cart is empty when it is empty. Now it keeps its own cart that nobody fills. Add removing and a total to the store and make CartSummary read the cart from there. The check adds three products, removes them and looks at the badge, the cards and the total.
Console
Solution
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
createApp(App).use(createPinia()).mount('#app');
App.vue<script setup>
import Header from './Header.vue';
import Catalog from './Catalog.vue';
import CartSummary from './CartSummary.vue';
</script>
<template>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</template>
cart.jsimport { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
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 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(id) {
items.value = items.value.filter((i) => i.id !== id);
}
return { items, count, total, add, remove };
});
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">The shop</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = storeToRefs(useCartStore());
</script>
<template>
<a href="/cart">Cart ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Products</h1>
<ProductList />
</section>
</template>
ProductList.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>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed } from 'vue';
import { useCartStore } from './cart.js';
import { money } from './money.js';
const props = defineProps(['product']);
const cart = useCartStore();
const qty = computed(() => cart.items.find((i) => i.id === props.product.id)?.qty ?? 0);
</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="cart.add(product)">
Add
</button>
</li>
</template>
CartSummary.vue<script setup>
import { useCartStore } from './cart.js';
import { money } from './money.js';
const cart = useCartStore();
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">The cart</h2>
<p v-if="cart.items.length === 0">The cart is empty</p>
<ul>
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Remove ${item.name}`" @click="cart.remove(item.id)">
Remove
</button>
</li>
</ul>
<p>Total: {{ money(cart.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; }
In cart.js add total with computed and an action remove(id) that replaces items.value with a filtered array, and return them from the store. CartSummary calls const cart = useCartStore() and uses cart.items, cart.total and cart.remove(item.id) in the template.
The check catches const { items, total, remove } = useCartStore(): the rows show, because the array is the same, but total is the number from the moment of creation and the total stays at 0.
The check also catches a copy of the cart in CartSummary: it is empty when made and does not follow the store.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Derived state · Lifting state up · Data from an API in a component · Forms in a component
- Continues in: Routing · From the sandbox to a project · The shop front
- Tasks on this topic: Effects, data and forms