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 through a callback.
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".
export default function App() { const [items, setItems] = useState([]); const count = items.reduce((sum, i) => sum + i.qty, 0); function add(product) { /* changes items */ } return ( <> <header><CartBadge count={count} /></header> <main><ProductList products={PRODUCTS} items={items} onAdd={add} /></main> </> );}onAdd={add} passes the function down. The child calls it when something happens: onClick={() => onAdd(product)}. Such a function is called a callback.
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.
function App() { const [items, setItems] = useState([]); const count = items.reduce((s, i) => s + i.qty, 0); function add(product) { setItems(plusOne(items, product)); } return ( <> <CartBadge count={count} /> <ProductList items={items} onAdd={add} /> </> );}
function ProductList({ items, onAdd }) { return PRODUCTS.map((p) => ( <ProductCard key={p.id} product={p} qty={qtyOf(items, p)} onAdd={onAdd} /> ));}
function ProductCard({ product, qty, onAdd }) { return <button onClick={() => onAdd(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 calls onAdd(product): a function that App passed down. The call travels up to add() in App.
- 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.jsximport { useState } from 'react';
import CartBadge from './CartBadge.jsx';
import ProductList from './ProductList.jsx';
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 },
];
export default function App() {
const [items, setItems] = useState([]);
const count = items.reduce((sum, i) => sum + i.qty, 0);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { ...product, qty: 1 }]);
}
return (
<>
<header>
<a href="/">The shop</a>
<CartBadge count={count} />
</header>
<main>
<h1>Products</h1>
<ProductList products={PRODUCTS} items={items} onAdd={add} />
</main>
</>
);
}
CartBadge.jsxexport default function CartBadge({ count }) {
return <a href="/cart">Cart ({count})</a>;
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
export default function ProductList({ products, items, onAdd }) {
const qtyOf = (id) => items.find((i) => i.id === id)?.qty ?? 0;
return (
<ul>
{products.map((p) => (
<ProductCard key={p.id} product={p} qty={qtyOf(p.id)} onAdd={onAdd} />
))}
</ul>
);
}
ProductCard.jsximport { money } from './money.js';
export default function ProductCard({ product, qty, onAdd }) {
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>In the cart: {qty}</span>}
<button type="button" aria-label={`Add ${product.name}`} onClick={() => onAdd(product)}>
Add
</button>
</li>
);
}
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. CartBadge gets count={count}, ProductList gets items={items} and onAdd={add} and passes onAdd on to every ProductCard. There is no useState in ProductList any more.
The check also catches onClick={onAdd(product)} in ProductCard. Without the arrow, onAdd is called already during render. It changes App, App renders again, the card calls onAdd again, and so on without end. The badge grows by itself without a single click, and the console says Cannot update a component (`App`) while rendering a different component (`ProductCard`).
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 names follow a convention: a prop with a function starts with on and names the event: onAdd, onRemove, onChange. The function in the parent is often add or handleAdd. You pass the function, not its result: onClick={() => onAdd(product)}, not onClick={onAdd(product)}.
Try it
Fill in
What goes in the blank so that the click reaches the parent?
export default function ProductCard({ product, onAdd }) {
return <button onClick={() => (product)}>Add</button>;
}onAdd: the function the parent passed as a prop. The arrow calls it only on click, with the card's product.Try it
Find the bug
Pick the line with the bug.
onClick={onAdd(product)} calls onAdd on every render of the card and gives onClick the result. It should be onClick={() => onAdd(product)}: a function that is called on click.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: Context. 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. In development mode React freezes the props object: props.qty = 3 throws TypeError: Cannot assign to read only property 'qty'. A child that wants a change reports up.
Why an arrow. onClick wants a function for React to call later. onClick={add} is a function too, but React calls it with the click event, not with the product. So onClick={() => onAdd(product)}: a new function that knows the product.
Class components have the same model: the parent passes onAdd as a prop and the child calls this.props.onAdd(product).
useState(items), the copy takes only the first value and stops following the parent. That is the mistake from the lesson on derived state.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.jsximport { useState } from 'react';
import CartBadge from './CartBadge.jsx';
import ProductList from './ProductList.jsx';
import CartSummary from './CartSummary.jsx';
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 },
];
export default function App() {
const [items, setItems] = useState([]);
const count = items.reduce((sum, i) => sum + i.qty, 0);
function add(product) {
const found = items.find((i) => i.id === product.id);
if (found) setItems(items.map((i) => (i.id === product.id ? { ...i, qty: i.qty + 1 } : i)));
else setItems([...items, { ...product, qty: 1 }]);
}
function remove(item) {
setItems(items.filter((i) => i.id !== item.id));
}
return (
<>
<header>
<a href="/">The shop</a>
<CartBadge count={count} />
</header>
<main>
<h1>Products</h1>
<ProductList products={PRODUCTS} items={items} onAdd={add} />
<CartSummary items={items} onRemove={remove} />
</main>
</>
);
}
CartBadge.jsxexport default function CartBadge({ count }) {
return <a href="/cart">Cart ({count})</a>;
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
export default function ProductList({ products, items, onAdd }) {
const qtyOf = (id) => items.find((i) => i.id === id)?.qty ?? 0;
return (
<ul>
{products.map((p) => (
<ProductCard key={p.id} product={p} qty={qtyOf(p.id)} onAdd={onAdd} />
))}
</ul>
);
}
ProductCard.jsximport { money } from './money.js';
export default function ProductCard({ product, qty, onAdd }) {
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>In the cart: {qty}</span>}
<button type="button" aria-label={`Add ${product.name}`} onClick={() => onAdd(product)}>
Add
</button>
</li>
);
}
CartSummary.jsximport { money } from './money.js';
export default function CartSummary({ items, onRemove }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return (
<section aria-labelledby="cart-title">
<h2 id="cart-title">The cart</h2>
{items.length === 0 && <p>The cart is empty</p>}
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Remove ${item.name}`} onClick={() => onRemove(item)}>
Remove
</button>
</li>
))}
</ul>
<p>Total: {money(total)}</p>
</section>
);
}
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 gets a function remove(item) that filters items. CartSummary gets items={items} and onRemove={remove}, and the button calls onRemove(item). The total in CartSummary is computed from items. Now the badge, the cards and the cart on the side read one state, so removing shows in all three places.
CartSummary (useState(props.items)): it is empty when created and does not see the products added later.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