Изчислено състояние
Накратко: Изчисленото състояние е стойност, която се смята от състоянието при рисуване (брой, сума, филтриран списък), вместо да се пази отделно; за скъпа сметка useMemo пази резултата между рисуванията.
Проблем
Сумата изостава
Кошницата има бял хляб и прясно мляко. Натисни „+“ на хляба: броят и сумата растат. Сега махни млякото.
Конзола
Редът изчезва, броят пада на 3, а сумата остава 5,20 €. Компонентът пази три състояния: items, count и total. Всеки клик трябва да смени и трите, а функцията за махане сменя две. Това е същото разминаване като в урока „Списък от данни“, само че вътре в компонент. Библиотеката рисува вярно това, което ѝ дадеш. Даваш ѝ две истини за една кошница.
Идеята
Изчисли, не пази
Попитай за всяко състояние: може ли да го изчисля от другото състояние или от props? Ако може, то не е състояние. Броят е сборът от количествата в items. Сумата е сборът от цена по количество. И двете се изчисляват.
Такава стойност се казва изчислено състояние (derived state). Пазиш само items, а останалото смяташ от тях всеки път, когато компонентът се рисува.
const [items, setItems] = useState(START);const count = items.reduce((sum, i) => sum + i.qty, 0);const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);setItems кара React да пусне функцията App наново. Тогава count и total се смятат от новите items. Това са обикновени константи. Не им трябва useState, не им трябва и нищо специално.
Сега функциите more и remove сменят само items. Няма какво да забравят.
Виж го
Виж изчисленото и копието
Вляво е кошницата и копие на сумата, пазено отделно. Вдясно са трите изчислени стойности.
const [items, setItems] = useState(START);const [savedTotal, setSavedTotal] = useState(26.9);
const count = items.reduce((s, i) => s + i.qty, 0);const total = items.reduce((s, i) => s + i.price * i.qty, 0);const free = total >= 30;
function more(item) { setItems(items.map((i) => i.id === item.id ? { ...i, qty: i.qty + 1 } : i, )); setSavedTotal(savedTotal + item.price);}
function remove(item) { setItems(items.filter((i) => i.id !== item.id));}Стъпка по стъпка, с думи
- Вляво е състоянието: кошницата items. Под нея има второ състояние, savedTotal: същата сума, пазена отделно.
- Вдясно са count, total и free: обикновени константи. React пуска App при всяко рисуване и те се смятат наново от items всеки път: 26,90 €, остават 3,10 € до безплатна доставка.
- more(сирене): items се сменя и трите стрелки светват. Сумата е 34,80 €, доставката е безплатна. more() обновява на ръка и savedTotal, затова копието още е вярно.
- remove(сирене): items се сменя, а count, total и free следват сами: 3,20 €. Ред за savedTotal в remove() никой не е написал.
- Копието казва 34,80 €, а кошницата е 3,20 €. Грешка няма, има невярно число: състоянието и копието му са се разминали.
- Правилото: пази само това, което не може да се изчисли. Всичко, което следва от състоянието, се смята от него, и копие за забравяне няма.
Изчислените стойности следват кошницата при всяка промяна, защото нямат собствена памет. Копието следва само докато някой се сеща да го обнови. Първата функция, която забрави, го прави невярно.
Пробвай
Изчисли броя и сумата
Кошницата пази count и total като отделни състояния и remove() забравя сумата. Махни двете състояния и изчисли броя и сумата от items. Проверката натиска „+“ на хляба, после маха млякото и гледа броя и сумата.
Конзола
Решение
App.jsximport { useState } from 'react';
import { money } from './money.js';
const START = [
{ id: 1, name: 'Бял хляб', price: 1.2, qty: 2 },
{ id: 5, name: 'Прясно мляко', price: 1.6, qty: 1 },
];
export default function App() {
const [items, setItems] = useState(START);
// Брой и сума: изчислени при всяко рисуване.
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
function more(item) {
setItems(items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)));
}
function remove(item) {
setItems(items.filter((i) => i.id !== item.id));
}
return (
<main>
<h1>Кошницата</h1>
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`Още ${item.name}`} onClick={() => more(item)}>+</button>
<button type="button" aria-label={`Махни ${item.name}`} onClick={() => remove(item)}>Махни</button>
</li>
))}
</ul>
<p>Продукти: {count}</p>
<p>Общо: {money(total)}</p>
</main>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
Остава само items. Броят е items.reduce((sum, i) => sum + i.qty, 0), сумата е същото с i.price * i.qty. В React това са константи в тялото на App: смятат се при всяко рисуване. more и remove вече сменят само кошницата.
Капан: const [total] = useState(items.reduce(…)). Началната стойност на useState се взима само при първото рисуване, затова сумата остава 4,00 € завинаги.
Пробвай
Попълни
Броят се изчислява от кошницата. Какво стои на празното място?
const [items, setItems] = useState(START);
count = items.reduce((sum, i) => sum + i.qty, 0);const: обикновена константа в тялото на компонента. Смята се наново при всяко рисуване, а useState тук би запомнил само първата стойност.Идеята
Кога да пазиш резултата
Константата се смята при всяко рисуване на компонента, дори когато items са същите и рисуването е заради друго състояние. За сума от десет реда това е нищо. Ако сметката е скъпа (подреждане на хиляди продукти), React има useMemo:
const sorted = useMemo(() => products.toSorted(byPrice), [products]);React пази резултата и пуска функцията наново само ако някоя от зависимостите в масива ([products]) е различна от предишното рисуване. Ако забравиш зависимост, резултатът остава стар. Затова useMemo не е за всяка сума. Започни с константа и го добави, когато измериш, че сметката е бавна. Резултатът е същият, само по-рядко се смята.
Идеята
Филтърът също е изчислен
Каталогът има бутони „Всички“, „Хляб“ и „Мляко“. Изкушението е да пазиш два масива: всички продукти и видимите. Но видимите следват от два други факта: пълния списък и избраната категория. Пазиш само категорията:
const [category, setCategory] = useState('all');const visible = category === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category);Същото е с подреждането: пазиш „по цена“ или „по име“, а подреденият списък го изчисляваш. Внимавай с sort(): той подрежда масива на място, тоест сменя състоянието. toSorted() връща нов масив и оставя стария.
Пробвай
Само категорията
Бутоните сменят категорията, но списъкът винаги показва всичко. Изчисли видимите продукти от категорията и покажи „Показани: 3 от 5“ над списъка. Проверката избира „Мляко“, после „Хляб“, после „Всички“.
Конзола
Решение
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard.jsx';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2, category: 'bread' },
{ id: 2, name: 'Пълнозърнест хляб', price: 2.1, category: 'bread' },
{ id: 5, name: 'Прясно мляко', price: 1.6, category: 'milk' },
{ id: 6, name: 'Кисело мляко', price: 0.9, category: 'milk' },
{ id: 7, name: 'Сирене', price: 7.9, category: 'milk' },
];
const CATEGORIES = [
{ id: 'all', name: 'Всички' },
{ id: 'bread', name: 'Хляб' },
{ id: 'milk', name: 'Мляко' },
];
export default function App() {
const [category, setCategory] = useState('all');
const visible = category === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category);
return (
<main>
<h1>Продукти</h1>
{CATEGORIES.map((c) => (
<button key={c.id} type="button" aria-pressed={category === c.id} onClick={() => setCategory(c.id)}>
{c.name}
</button>
))}
<p>Показани: {visible.length} от {PRODUCTS.length}</p>
<ul>
{visible.map((p) => <ProductCard key={p.id} product={p} />)}
</ul>
</main>
);
}
ProductCard.jsximport { money } from './money.js';
export default function ProductCard({ product }) {
return (
<li>
<span>{product.name}</span> {money(product.price)}
</li>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
visible се изчислява от category и PRODUCTS: при „Всички“ е целият списък, иначе filter по категория. Броят е visible.length от PRODUCTS.length.
Ако visible е второ състояние, кодът често става такъв: setCategory(id) и веднага setVisible(… category …). Но category в този клик е още старата стойност: новата идва със следващото рисуване. Списъкът е с един клик назад. С изчислена стойност този проблем не съществува.
Идеята
Копие на prop
Същата грешка има и между компоненти. App дава намаление 20% и подава новите цени на всяка ProductCard. Картата обаче е запомнила цената, която е получила първия път:
Конзола
Натисни „Намаление 20%“. Бутонът е натиснат, а хлябът още е 1,20 €.
const [price] = useState(product.price) взима product.price само при първото рисуване на картата. После React рисува картата с новия product, но състоянието ѝ е нейно и остава.
Поправката е да не копираш: показвай product.price направо. Ако картата трябва да покаже нещо, изчислено от prop (цена с ДДС, цена на брой), изчисли го при рисуване.
Пробвай
Намери грешката
Купувачът натиска „+“: количеството на реда става 3, а сумата на реда остава като за 2. Кой ред е виновен?
Избери реда с грешката.
item.price * item.qty в състояние. useState взима началната стойност веднъж, затова сумата на реда не следва количеството. Трябва const subtotal = item.price * item.qty;.По-точно, за любопитните
Кога копието е нарочно. Понякога искаш стойност отвън само за начало: формуляр за редакция започва с текущото име, а после човекът пише. Тогава копието е правилно, но го кажи в името: prop initialName, състояние name. Който чете кода, вижда, че промените отвън не се следят.
useMemo е подсказка. React си пази правото да изхвърли запомнения резултат и да смята наново, и документацията го казва изрично. Затова кодът трябва да е верен и без useMemo. React Compiler, ако проектът го ползва, сам запомня такива сметки, и ръчният useMemo става още по-рядко нужен.
Рисуването е често. Компонентът се рисува наново, когато се смени неговото състояние или когато се рисува родителят му. Константите в него се смятат всеки път. Затова в тялото на компонента няма fetch, таймери или промяна на състояние: това е за ефектите, следващия модул.
Същото важи за данни от сървъра. Ако сървърът връща продуктите, а ти пазиш отделно „брой продукти“, след ново зареждане двете могат да се разминат. Пази отговора, а броя изчислявай.
Упражнение
Безплатна доставка
Кошницата има бутони „−“ и „+“ за всеки ред (при „−“ на последната бройка редът изчезва). Под списъка покажи „Продукти: 4“, „Общо: 18,20 €“ и „До безплатна доставка: още 11,80 €“, а над 30 € (FREE_FROM) само „Доставката е безплатна“. Всичко се изчислява от items. Проверката добавя сирене до 34,00 €, после маха до под 30 € и маха хляба.
Конзола
Решение
App.jsximport { useState } from 'react';
import { money } from './money.js';
const START = [
{ id: 7, name: 'Сирене', price: 7.9, qty: 2 },
{ id: 1, name: 'Бял хляб', price: 1.2, qty: 2 },
];
const FREE_FROM = 30;
export default function App() {
const [items, setItems] = useState(START);
const count = items.reduce((sum, i) => sum + i.qty, 0);
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const free = total >= FREE_FROM;
function more(item) {
setItems(items.map((i) => (i.id === item.id ? { ...i, qty: i.qty + 1 } : i)));
}
function less(item) {
if (item.qty === 1) setItems(items.filter((i) => i.id !== item.id));
else setItems(items.map((i) => (i.id === item.id ? { ...i, qty: i.qty - 1 } : i)));
}
return (
<main>
<h1>Кошницата</h1>
<ul>
{items.map((item) => (
<li key={item.id}>
<span>{item.name} × {item.qty}</span>
<button type="button" aria-label={`По-малко ${item.name}`} onClick={() => less(item)}>−</button>
<button type="button" aria-label={`Още ${item.name}`} onClick={() => more(item)}>+</button>
</li>
))}
</ul>
<p>Продукти: {count}</p>
<p>Общо: {money(total)}</p>
{free ? <p>Доставката е безплатна</p> : <p>До безплатна доставка: още {money(FREE_FROM - total)}</p>}
</main>
);
}
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
Броят, сумата и free са изчислени от items; съобщението за доставка е условно показване: free или останалата сума FREE_FROM - total. Проверката минава и над 30 €, и обратно под тях. Затова „безплатна“, пазена като отделно състояние, се хваща: тя става true при „+“, но никой не я връща при „−“. Със същата цел проверката хваща и useMemo(…, []): празният масив казва „никога не смятай наново“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци и ключове · Условия: празно, грешка, зарежда · Състояние · Обновяване на обекти и масиви
- Продължава в: Вдигане на състоянието · Ефекти · Общо състояние · Рутиране
- Задачи по темата: Състояние