Общо състояние
Накратко: Общото състояние в React се дава от доставчик горе в дървото с Context и useReducer, а компонентите го четат направо, без props през всички нива.
Проблем
Броячът пак казва 0
Кошницата е повдигната в App, както в урока за общото състояние нагоре. Оттам тя слиза като props: към Header и от него към CartBadge, към Catalog, от него към ProductList и от него към всяка ProductCard. Някой пренаписа Header и броячът спря.
Конзола
Добави хляб, мляко и пак хляб. Картите броят, а горе пише „Кошница (0)“. App подава count на Header, но новият Header не го подава на CartBadge. Header не ползва кошницата. Той трябваше само да я предаде нататък и точно това е забравил.
Идеята
Доставчик горе, потребители долу
Подаването през компоненти, които не ползват данните, се казва prop drilling (прокарване на props). При две нива е нормално. При пет всеки компонент по пътя зависи от кошницата, а един пропуснат prop къса веригата.
Двете библиотеки имат начин да дадат данни на далечни потомци без компонентите по средата:
- Доставчикът е компонентът, който притежава данните. Той казва: „всичко под мен може да чете това“.
- Потребителят е компонентът, който ги ползва. Той ги взима направо, без значение колко нива са между тях.
В React това е Context:
export const CartContext = createContext(null);
// App.jsx<CartContext value={{ items, count, add }}> <Header /> <main><Catalog /></main></CartContext>
// CartBadge.jsxconst { count } = useContext(CartContext);createContext(null) прави „канала“. null е стойността, ако няма доставчик над компонента. <CartContext value={…}> е доставчикът. useContext(CartContext) чете стойността на най-близкия доставчик нагоре по дървото. Когато стойността се смени, всеки компонент, който я чете, се рисува наново.
Данните пак имат един собственик и промените пак минават през него: add е функцията на доставчика. Сменен е само пътят.
Виж го
Виж двата пътя
Отначало кошницата слиза ниво по ниво и веригата се къса по средата. После App става доставчик и двамата потребители четат направо.
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">Кошница ({count})</a>;}
function ProductCard({ product }) { const { add } = useContext(CartContext); return <button onClick={() => add(product)}>Добави</button>;}Стъпка по стъпка, с думи
- Кошницата живее в App. Трябва на два компонента далеч надолу: CartBadge в горната част и ProductCard в каталога.
- Само с props кошницата слиза ниво по ниво. Header, Catalog и ProductList не я ползват. Те само я подават.
- Някой пренаписва Header и забравя count. Веригата се къса по средата и броячът показва 0. Това е прокарването на props.
- Вместо това: App обвива дървото в <CartContext value={…}>. Всичко под него може да чете кошницата.
- CartBadge и ProductCard я четат с useContext(CartContext). Компонентите по средата не знаят нищо за кошницата.
- Клик на „Добави“ вика add(product). Функцията е дошла от доставчика, затова промяната става в App.
- Кошницата в App се сменя и всеки компонент, който я чете, се рисува наново: броячът казва 1, картата казва 1.
- Данните пак имат един собственик, App. Сменен е само пътят: не през всяко ниво, а направо от доставчика.
Компонентите по средата вече не знаят нищо за кошницата. Новият Header може да бъде пренаписан колкото искаш.
Пробвай
Махни прокарването
Кошницата слиза като props през Header и Catalog, а Header изпуска count. Дай кошницата отгоре. Header, Catalog и ProductList не получават нищо за кошницата, а CartBadge и ProductCard я четат направо. Проверката добавя хляб, мляко и хляб и гледа картите и брояча.
Конзола
Решение
App.jsximport { useState } from 'react';
import { CartContext } from './cart.js';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
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 (
<CartContext value={{ items, count, add }}>
<Header />
<main>
<Catalog />
</main>
</CartContext>
);
}
Header.jsximport CartBadge from './CartBadge.jsx';
export default function Header() {
return (
<header>
<a href="/">Магазинът</a>
<CartBadge />
</header>
);
}
CartBadge.jsximport { useContext } from 'react';
import { CartContext } from './cart.js';
export default function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Кошница ({count})</a>;
}
Catalog.jsximport ProductList from './ProductList.jsx';
export default function Catalog() {
return (
<section>
<h1>Продукти</h1>
<ProductList />
</section>
);
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
];
export default function ProductList() {
return (
<ul>
{PRODUCTS.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
ProductCard.jsximport { useContext } from 'react';
import { CartContext } from './cart.js';
import { money } from './money.js';
export default function ProductCard({ product }) {
const { items, add } = useContext(CartContext);
const qty = items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>В кошницата: {qty}</span>}
<button type="button" aria-label={`Добави ${product.name}`} onClick={() => add(product)}>
Добави
</button>
</li>
);
}
cart.jsimport { createContext } from 'react';
export const CartContext = createContext(null);
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; }
CartContext вече е в cart.js. В App обвий Header и main в <CartContext value={{ items, count, add }}>. CartBadge чете const { count } = useContext(CartContext), ProductCard чете items и add и сам смята своето qty. От Catalog и ProductList махни props за кошницата.
Проверката хваща доставчик, който обвива само main: Header остава извън него, useContext връща null и CartBadge пада. Хваща и стойност без count.
Проверката вижда само поведението. Ако подадеш count през Header още веднъж, тя ще мине. Целта тук е друга: средата да не знае за кошницата.
Идеята
Хранилище за кошницата
Кошницата расте: добави, махни, смени броя, изпразни. Ако всяко действие е функция в App, App се пълни с логика, която не е за него. Затова общото състояние се изнася в отделен файл, хранилище (store): данните и всичко, което ги сменя, на едно място.
React има за това вградената кука useReducer. Вместо setItems(…) компонентите изпращат действие (dispatch({ type: 'add', product })), а една функция, reducer, решава какво става с кошницата:
function cartReducer(items, action) { switch (action.type) { case 'add': /* нов масив с продукта */ 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 е доставчикът, а App само го слага около дървото. Потребителите не се сменят: те пак четат useContext(CartContext). Reducer-ът е обикновена функция: стара кошница и действие влизат, нова кошница излиза. Тя не пипа нищо друго и затова лесно се тества.
Context и useReducer са вградени в React. Има и библиотеки за хранилища, например Zustand и Redux Toolkit. Те ги няма в пясъчника и тук само ги споменаваме: идеята е същата, един източник и действия, които го сменят.
Конзола
cart.jsx: там е цялата логика на кошницата. CartBadge и ProductCard са същите като в задачата.Пробвай
Намери грешката
TypeError: Cannot destructure property 'count' of … as it is null. Кой ред е виновен?Избери реда с грешката.
<Header /> е извън <CartContext value={…}>. CartBadge в него търси доставчик нагоре и не намира, затова useContext връща null от createContext(null). Доставчикът трябва да обвива и Header.Идеята
Кога общо и кога не
Общото състояние е удобно и затова изкушава всичко да отиде там. Но всяко нещо в него може да се чете и сменя отвсякъде, а това прави грешките по-трудни за намиране. Правилото от урока за повдигането важи: толкова високо, колкото трябва, и не по-високо.
- Общо: това, което трябва на далечни части на дървото и живее, докато потребителят е на сайта. Кошницата, влезлият потребител, езикът, темата.
- В компонента: полетата на формуляра, дали менюто е отворено, кой раздел е избран, текстът в търсачката. Те трябват на един компонент (или на родителя му) и изчезват с него.
- Данните от сървъра не са общо състояние. Списъкът с продукти и поръчките имат собственик и той е сървърът. В хранилището те са копие, което трябва да се опреснява. Компонентът, който ги показва, ги зарежда, както в урока за данните от API.
Общото състояние не сменя посоката: данните тръгват от един собственик, а промените минават през неговите функции.
Пробвай
Попълни
CartBadge чете брояча от доставчика?import { useContext } from 'react';
import { CartContext } from './cart.js';
export default function CartBadge() {
const { count } = (CartContext);
return <a href="/cart">Кошница ({count})</a>;
}useContext: той чете стойността на най-близкия <CartContext value={…}> над компонента и го рисува наново, когато тя се смени.По-точно, за любопитните
<CartContext.Provider>. До React 19 доставчикът се пишеше <CartContext.Provider value={…}>. Този запис още работи, а <CartContext value={…}> е новият, по-къс.
Собствена кука. Често до контекста има малка функция export function useCart() { return useContext(CartContext); }. Тя може и да хвърли ясна грешка, ако няма доставчик, вместо TypeError за null.
Кой се рисува наново. Всеки компонент, който чете контекста, се рисува наново, когато стойността му се смени. value={{ items, count, add }} е нов обект при всяко рисуване на доставчика. В голямо приложение това се пази с useMemo или с няколко по-малки контекста. В урока за бързината ще видиш как се мери.
Класовите компоненти четат контекст със static contextType или с <CartContext.Consumer>. Срещат се в стар код.
Хранилището живее, докато страницата е отворена. При презареждане се губи. Кошница, която трябва да оцелее, се записва в localStorage или на сървъра.
Проверките виждат поведението. Задачите тук не могат да видят дали кошницата идва от props, от контекст или от хранилище. Те проверяват, че броячът и картите показват едно и също.
Упражнение
Страницата на кошницата
В главната част има CartSummary: заглавие „Кошницата“, редове „Бял хляб × 2“ с бутон „Махни“, „Общо: 4,00 €“ и „Кошницата е празна“, когато е празна. Сега тя пази своя кошница, която никой не пълни. Добави в хранилището махане и обща сума и направи CartSummary да чете кошницата оттам. Проверката добавя три продукта, маха ги и гледа брояча, картите и сумата.
Конзола
Решение
App.jsximport { CartProvider } from './cart.jsx';
import Header from './Header.jsx';
import Catalog from './Catalog.jsx';
import CartSummary from './CartSummary.jsx';
export default function App() {
return (
<CartProvider>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</CartProvider>
);
}
cart.jsximport { createContext, useReducer } from 'react';
export const CartContext = createContext(null);
function cartReducer(items, action) {
switch (action.type) {
case 'add': {
const found = items.find((i) => i.id === action.product.id);
if (found) return items.map((i) => (i.id === found.id ? { ...i, qty: i.qty + 1 } : i));
return [...items, { ...action.product, qty: 1 }];
}
case 'remove':
return items.filter((i) => i.id !== action.id);
default:
return items;
}
}
export function CartProvider({ children }) {
const [items, dispatch] = useReducer(cartReducer, []);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const add = (product) => dispatch({ type: 'add', product });
const remove = (id) => dispatch({ type: 'remove', id });
return <CartContext value={{ items, count, total, add, remove }}>{children}</CartContext>;
}
Header.jsximport CartBadge from './CartBadge.jsx';
export default function Header() {
return (
<header>
<a href="/">Магазинът</a>
<CartBadge />
</header>
);
}
CartBadge.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
export default function CartBadge() {
const { count } = useContext(CartContext);
return <a href="/cart">Кошница ({count})</a>;
}
Catalog.jsximport ProductList from './ProductList.jsx';
export default function Catalog() {
return (
<section>
<h1>Продукти</h1>
<ProductList />
</section>
);
}
ProductList.jsximport ProductCard from './ProductCard.jsx';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
];
export default function ProductList() {
return (
<ul>
{PRODUCTS.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</ul>
);
}
ProductCard.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
import { money } from './money.js';
export default function ProductCard({ product }) {
const { items, add } = useContext(CartContext);
const qty = items.find((i) => i.id === product.id)?.qty ?? 0;
return (
<li>
<span>{product.name}, {money(product.price)}</span>
{qty > 0 && <span>В кошницата: {qty}</span>}
<button type="button" aria-label={`Добави ${product.name}`} onClick={() => add(product)}>
Добави
</button>
</li>
);
}
CartSummary.jsximport { useContext } from 'react';
import { CartContext } from './cart.jsx';
import { money } from './money.js';
export default function CartSummary() {
const { items, total, remove } = useContext(CartContext);
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={() => remove(item.id)}>
Махни
</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; }
В cart.jsx добави действие case 'remove': return items.filter((i) => i.id !== action.id);, функция remove = (id) => dispatch({ type: 'remove', id }) и total, и ги сложи в value. CartSummary чете const { items, total, remove } = useContext(CartContext) и вика remove(item.id).
Проверката хваща reducer, който маха със splice и връща същия масив: React не вижда промяна и нищо не се рисува. Хваща и CartSummary, сложен извън CartProvider.
Проверката хваща и копие на кошницата в CartSummary: то е празно при създаването и не следва хранилището.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изчислено състояние · Вдигане на състоянието · Данни от API в компонент · Формуляри в компонент
- Продължава в: Рутиране · От пясъчника към проект · Витрината на магазина
- Задачи по темата: Ефекти, данни и формуляри