Skip to content
wannacode

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.

Vue

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:

  1. The provider is the component that owns the data. It says: "everything under me can read this".
  2. 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:

App.vue
<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
  1. The cart lives in App. Two components far below need it: CartBadge in the header and ProductCard in the catalog.
  2. With props only, the cart goes down level by level. Header, Catalog and ProductList do not use it. They only pass it on.
  3. Someone rewrites Header and forgets count. The chain breaks in the middle, and the badge shows 0. That is prop drilling.
  4. Instead: App calls provide('cart', …). Every descendant of App can read the cart.
  5. CartBadge and ProductCard read it with inject('cart'). The components in between know nothing about the cart.
  6. A click on “Add” calls add(product). The function came from the provider, so the change happens in App.
  7. The cart in App changes, and every component that reads it draws again: the badge says 1, the card says 1.
  8. 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.

Vue

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.

cart.js
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.

Vue
Open 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

The cards count, and the badge stays at "Cart (0)". There is no error in the console. Which line is to blame?

Pick the line with the bug.

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

How does 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>
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.

Vue

Test

Check yourself

1. The cart goes down from App through Catalog and ProductList to ProductCard. Catalog and ProductList do not use it. What is the problem?
2. CartBadge says const { count } = useCartStore();. What does the badge show?
3. ProductCard calls inject('cart'), and nobody above it has called provide('cart', …). What happens?
4. Which of these belongs in the shared state?
5. The product list comes from /api/products. Should it go into the shared store?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·