Skip to content
wannacode

Shared state

In short: Shared state in React comes from a provider high in the tree with Context and useReducer, 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.

React

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 React this is Context:

cart.js
export const CartContext = createContext(null);
// App.jsx
<CartContext value={{ items, count, add }}>
<Header />
<main><Catalog /></main>
</CartContext>
// CartBadge.jsx
const { count } = useContext(CartContext);

createContext(null) makes the "channel". null is the value when there is no provider above the component. <CartContext value={…}> is the provider. useContext(CartContext) reads the value of the closest provider up the tree. When the value changes, every component that reads it renders again.

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.

const CartContext = createContext(null);
function App() {
const [items, setItems] = useState([]);
const count = items.reduce((s, i) => s + i.qty, 0);
function add(product) { /* … */ }
return (
<CartContext value={{ items, count, add }}>
<Header />
<Catalog />
</CartContext>
);
}
function Header() {
return <header><CartBadge /></header>;
}
function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Cart ({count})</a>;
}
function ProductCard({ product }) {
const { add } = useContext(CartContext);
return <button onClick={() => add(product)}>Add</button>;
}
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 wraps the tree in <CartContext value={…}>. Everything under it can read the cart.
  5. CartBadge and ProductCard read it with useContext(CartContext). 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.

React

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.

React has the built-in hook useReducer for this. Instead of setItems(…) the components send an action (dispatch({ type: 'add', product })), and one function, the reducer, decides what happens to the cart:

function cartReducer(items, action) {
switch (action.type) {
case 'add': /* a new array with the product */
default:
return items;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const add = (product) => dispatch({ type: 'add', product });
return <CartContext value={{ items, count, add }}>{children}</CartContext>;
}

CartProvider is the provider, and App only puts it around the tree. The consumers do not change: they still read useContext(CartContext). The reducer is a plain function: the old cart and an action go in, a new cart comes out. It touches nothing else, so it is easy to test.

Context and useReducer are built into React. There are also libraries for stores, for example Zustand and Redux Toolkit. They are not in the sandbox, and here we only mention them: the idea is the same, one source and actions that change it.

React
Open cart.jsx: all the logic of the cart is there. CartBadge and ProductCard are the same as in the task.

Try it

Find the bug

The page crashes as soon as it opens: TypeError: Cannot destructure property 'count' of … as it is null. 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 read the count from the provider?
import { useContext } from 'react';
import { CartContext } from './cart.js';

export default function CartBadge() {
  const { count } = (CartContext);
  return <a href="/cart">Cart ({count})</a>;
}
More precisely, if you are curious

<CartContext.Provider>. Before React 19 the provider was written <CartContext.Provider value={…}>. That form still works, and <CartContext value={…}> is the new, shorter one.

Your own hook. Next to a context there is often a small function export function useCart() { return useContext(CartContext); }. It can also throw a clear error when there is no provider, instead of a TypeError about null.

Who renders again. Every component that reads the context renders again when its value changes. value={{ items, count, add }} is a new object on every render of the provider. In a big application this is guarded with useMemo or with several smaller contexts. In the lesson about speed you will see how to measure it.

Class components read a context with static contextType or with <CartContext.Consumer>. You meet them in old code.

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.

React

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 calls useContext(CartContext), but it is outside <CartContext value={…}>. What does it get?
3. Why does cartReducer return a new array instead of doing items.push(…)?
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 ·