Вдигане на състоянието
Накратко: Вдигането на състоянието (lifting state up) мести общото състояние в най-близкия общ родител: данните слизат като props, а промените се качват с callback.
Проблем
Броячът горе не знае
Магазинът има брояч на кошницата горе и списък с продукти долу. Добави хляб, мляко и още един хляб.
Конзола
Картата на хляба казва „В кошницата: 2“, а горе пише „Кошница (0)“. Кошницата живее в ProductList: там е натиснат бутонът и там е добавено състоянието. CartBadge е друг компонент, брат на ProductList, и има свое число, което никой не сменя. Двата компонента имат две кошници и всеки е прав за своята.
Идеята
Нагоре до общия родител
Компонентът вижда само своето състояние и props, които му подава родителят. Брат не вижда брат си. Затова две места, които показват една кошница, не може всяко да има своя.
Решението е да качиш състоянието в най-близкия общ родител на двамата. Тук това е App. Това се казва повдигане на състоянието (lifting state up):
Appпазиitemsи изчисляваcount.- Данните слизат като props:
countкъмCartBadge,itemsкъмProductList. - Промените се качват като съобщение: бутонът в
ProductCardне сменя кошницата, а казва наApp„добави този продукт“.
export default function App() { const [items, setItems] = useState([]); const count = items.reduce((sum, i) => sum + i.qty, 0); function add(product) { /* сменя items */ } return ( <> <header><CartBadge count={count} /></header> <main><ProductList products={PRODUCTS} items={items} onAdd={add} /></main> </> );}onAdd={add} подава функцията надолу. Детето я вика, когато нещо се случи: onClick={() => onAdd(product)}. Такава функция се казва callback.
Сега CartBadge и ProductList нямат свое състояние за кошницата. Те показват каквото им даде App.
Виж го
Виж как се качва
Отначало кошницата е в ProductList. После отива в App и оттам тече надолу.
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>;}Стъпка по стъпка, с думи
- CartBadge и ProductList са братя: и двата са деца на App. Отначало кошницата живее в ProductList, а CartBadge има свой брояч.
- Клик на „Добави“ стига до ProductList и списъкът брои 1. CartBadge не вижда това състояние: брат не чете състоянието на брат си. Той остава 0.
- Поправката: кошницата отива в най-близкия общ родител на двамата, App. Броячът вече няма свое копие.
- От App данните текат надолу като props: count към CartBadge, items към ProductList и qty към всяка ProductCard. Двамата братя четат една кошница.
- Детето не притежава кошницата, затова не я сменя. Кликът вика onAdd(product): функция, която App е подал надолу. Извикването стига до add() в App.
- App сменя items и новите props текат надолу: броячът казва 2, картата казва 2. Никой не ги е обновявал на ръка.
- Правилото: състоянието е на едно място, толкова високо, колкото трябва, и не по-високо. Данните слизат като props, промените се качват като събития.
Посоката е една и съща във всяко приложение: данните надолу, съобщенията нагоре. Който притежава данните, само той ги сменя.
Пробвай
Качи кошницата
Кошницата е в ProductList, а CartBadge брои свое число. Премести кошницата в App: CartBadge получава броя като prop, ProductList получава items и съобщава за „Добави“ нагоре. Проверката добавя хляб, мляко и хляб и гледа картите и брояча.
Конзола
Решение
App.jsximport { useState } from 'react';
import CartBadge from './CartBadge.jsx';
import ProductList from './ProductList.jsx';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', 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="/">Магазинът</a>
<CartBadge count={count} />
</header>
<main>
<h1>Продукти</h1>
<ProductList products={PRODUCTS} items={items} onAdd={add} />
</main>
</>
);
}
CartBadge.jsxexport default function CartBadge({ count }) {
return <a href="/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>В кошницата: {qty}</span>}
<button type="button" aria-label={`Добави ${product.name}`} onClick={() => onAdd(product)}>
Добави
</button>
</li>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
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 и count отиват в App. CartBadge получава count={count}, ProductList получава items={items} и onAdd={add} и подава onAdd на всяка ProductCard. В ProductList вече няма useState.
Проверката хваща и onClick={onAdd(product)} в ProductCard. Без стрелката onAdd се вика още при рисуването. Тя сменя App, App се рисува наново, картата вика onAdd пак, и така без край. Броячът расте сам, без нито един клик, а конзолата казва Cannot update a component (`App`) while rendering a different component (`ProductCard`).
Идеята
Детето съобщава, родителят решава
ProductCard не знае как е устроена кошницата. Тя знае само, че някой е натиснал „Добави“ на нейния продукт. Затова съобщава какво се е случило, а не какво да се промени: „добави този продукт“, а не „увеличи qty на ред 2“. App решава какво значи това: нов ред или още една бройка.
Така картата става по-проста и може да стои навсякъде: в каталога, в „Препоръчани“, в търсенето. Тя не притежава данните и не ги пипа.
Имената следват една конвенция: prop с функция започва с on и казва събитието: onAdd, onRemove, onChange. Функцията в родителя често е add или handleAdd. Подаваш функцията, а не резултата ѝ: onClick={() => onAdd(product)}, не onClick={onAdd(product)}.
Пробвай
Попълни
Какво стои на празното място, за да стигне кликът до родителя?
export default function ProductCard({ product, onAdd }) {
return <button onClick={() => (product)}>Добави</button>;
}onAdd: функцията, която родителят е подал като prop. Стрелката я вика едва при клик, с продукта на картата.Пробвай
Намери грешката
Избери реда с грешката.
onClick={onAdd(product)} вика onAdd при всяко рисуване на картата и дава на onClick резултата. Трябва onClick={() => onAdd(product)}: функция, която се вика при клик.Идеята
Когато дървото стане дълбоко
Колко нагоре? Толкова високо, колкото трябва, и не по-високо. Ако само ProductCard ползва нещо (дали е отворено описанието), то остава в нея. Качваш го, когато втори компонент трябва да го види.
В истинския магазин кошницата трябва на много места: броячът горе, списъкът, страницата на продукта, поръчката. Общият родител е високо, а пътят до тях минава през компоненти, които не ползват кошницата, само я подават надолу. Това се казва prop drilling (прокарване на props). При две нива е нормално. При пет нива всеки компонент по пътя трябва да знае за данни, които не са негови.
За това има отделни инструменти: Context. Те дават данните на далечни потомци без компонентите по пътя. Те са в урока за общото състояние от модула „Ефекти, данни и формуляри“. Правилото остава същото: данните имат един собственик, а промените минават през него.
По-точно, за любопитните
Props са само за четене. В режим на разработка React замразява обекта с props: props.qty = 3 хвърля TypeError: Cannot assign to read only property 'qty'. Детето, което иска промяна, съобщава нагоре.
Защо стрелка. onClick иска функция, която React да извика по-късно. onClick={add} също е функция, но я вика със събитието на клика, а не с продукта. Затова onClick={() => onAdd(product)}: нова функция, която знае продукта.
Класовите компоненти имат същия модел: родителят подава onAdd като prop и детето вика this.props.onAdd(product).
useState(items), копието взима само първата стойност и спира да следва родителя. Това е грешката от урока за изчисленото състояние.Упражнение
Кошницата отстрани
До списъка има трета част, CartSummary: заглавие „Кошницата“, редове „Бял хляб × 2“ с бутон „Махни“, „Общо: 4,00 €“ и „Кошницата е празна“, когато е празна. Сега CartSummary пази своя кошница, която никой не пълни. Подай ѝ кошницата от App и направи „Махни“ да маха от нея. Проверката добавя три продукта, маха ги и гледа брояча, картите и сумата.
Конзола
Решение
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: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', 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="/">Магазинът</a>
<CartBadge count={count} />
</header>
<main>
<h1>Продукти</h1>
<ProductList products={PRODUCTS} items={items} onAdd={add} />
<CartSummary items={items} onRemove={remove} />
</main>
</>
);
}
CartBadge.jsxexport default function CartBadge({ count }) {
return <a href="/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>В кошницата: {qty}</span>}
<button type="button" aria-label={`Добави ${product.name}`} onClick={() => onAdd(product)}>
Добави
</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">Кошницата</h2>
{items.length === 0 && <p>Кошницата е празна</p>}
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Махни ${item.name}`} onClick={() => onRemove(item)}>
Махни
</button>
</li>
))}
</ul>
<p>Общо: {money(total)}</p>
</section>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
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 получава функция remove(item), която филтрира items. CartSummary получава items={items} и onRemove={remove}, а бутонът вика onRemove(item). Сумата в CartSummary е изчислена от items. Сега броячът, картите и кошницата отстрани четат едно състояние, затова махането се вижда и на трите места.
CartSummary (useState(props.items)): то е празно при създаването и не вижда добавените продукти.Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Условия: празно, грешка, зарежда · Състояние · Обновяване на обекти и масиви · Изчислено състояние
- Продължава в: Общо състояние · Рутиране · Бързина: кога се рисува наново · Тестове на компоненти
- Задачи по темата: Състояние