Обновяване на обекти и масиви
Накратко: Обект или масив в състоянието на React се обновява с ново копие (spread, map, filter), защото React сравнява референции и не вижда промяна на стария.
Проблем
„Махни“ не маха
Кошницата има три реда, всеки с бутон „Махни“. Натисни „Махни“ на хляба.
Конзола
Конзолата казва редове: 2. На екрана са трите реда и старата сума. Данните са сменени, а екранът не ги следва, и грешка няма.
remove маха реда със splice и вика setCart(cart). Новата стойност е подадена. Защо React не рисува?Идеята
Как библиотеката вижда промяната
С число е ясно: 1 и 2 са различни. Масивът и обектът обаче са референции: променливата сочи обект в паметта. Има два въпроса. Дали променливата сочи друг обект? И дали съдържанието на същия обект е сменено? Всяка библиотека пита по свой начин, и тук разликата се вижда в кода.
React пита само първия въпрос. При setCart(next) той сравнява старото и новото състояние с Object.is. За масив това е „същият обект ли е“, а не „същото съдържание ли има“.
splice сменя съдържанието на същия масив. setCart(cart) подава същата референция, Object.is казва „същото“ и React пропуска рисуването. Затова в React масивът и обектите в състоянието не се сменят на място. Прави се нов масив и React вижда нова референция. Това се казва непроменимо обновяване (immutable update).
Виж го
Виж: същият масив или нов
const [cart, setCart] = useState([bread]);
function addMilk() { cart.push(milk); setCart(cart);}
function addCheese() { setCart([...cart, cheese]);}Стъпка по стъпка, с думи
- cart сочи масив с един ред. React пази тази референция като състоянието на компонента, а екранът показва хляба.
- cart.push(milk) добавя ред в същия масив. Съдържанието е друго, но обектът е същият.
- setCart(cart) подава същата референция. React сравнява старата и новата с Object.is: същият обект.
- Същото значи „нищо ново“: React пропуска рисуването. Млякото е в масива, а не на екрана.
- [...cart, cheese] прави нов масив: копие на редовете и сиренето.
- setCart с новия масив: Object.is казва „различни“ и React рисува наново.
- Сега на екрана са трите реда. И млякото, което досега беше скрито в стария масив.
- Правилото в React: не сменяй масива в състоянието, а му дай нов.
push, но на екрана се появява чак със сиренето. Кодът с мутация не просто не работи. Той крие промяна, която излиза по-късно, при съвсем друго действие.Идеята
Нов масив, нов обект
Кошницата има три действия: добави ред, смени броя на един ред, махни ред.
// добави: нов масив със стария и новия редsetCart([...cart, { ...product, qty: 1 }]);
// смени един ред: нов масив, сменият ред е нов обектsetCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line)));
// махни: нов масив без редаsetCart(cart.filter((line) => line.id !== id));[...cart, x]е нов масив: старите елементи иxнакрая.{ ...line, qty: 5 }е нов обект: полетата наline, аqtyе сменено.mapиfilterвинаги връщат нов масив. Затова са основните инструменти тук.
Пробвай
Поправи „Махни“
Това е кошницата от началото. Поправи remove така, че редът да изчезва и сумата да се смята наново. Проверката маха хляба, после сиренето.
Конзола
Решение
App.jsximport { useState } from 'react';
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function App() {
const [cart, setCart] = useState([
{ id: 1, name: 'Бял хляб', price: 1.2, qty: 2 },
{ id: 5, name: 'Прясно мляко', price: 1.6, qty: 1 },
{ id: 7, name: 'Сирене', price: 7.9, qty: 1 },
]);
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h2>Кошницата</h2>
<ul>
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => remove(line.id)} aria-label={`Махни ${line.name}`}>
Махни
</button>
</li>
))}
</ul>
<p>Общо: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
setCart(cart.filter((line) => line.id !== id)): filter връща нов масив без реда и React го вижда. const next = cart не е копие: next и cart сочат един и същ масив, и splice върху next е splice върху състоянието.Пробвай
Намери грешката
„Махни“ не маха. Кой ред е виновен?
Избери реда с грешката.
Ред 2: const next = cart не копира масива, а дава второ име на същия. splice сменя състоянието на място и setCart(next) подава същата референция. Трябва setCart(cart.filter((line) => line.id !== id)).
Идеята
Един ред от масива
Купувачът натиска „+“ на хляба. Трябва да се смени qty на един обект, а той е в масив.
cart.find(...).qty++ сменя обекта на място: той е същият обект, който React пази като старо състояние. Масивът е същият, затова React не рисува. Числото не изчезва: то излиза на екрана при следващото рисуване по друга причина и броят „скача“.
Внимавай и с копията. [...cart] е нов масив, но редовете в него са същите обекти. Това е плитко копие: копира се само първото ниво. copy[0].qty++ сменя и реда в старото състояние. Сменяният ред трябва да е нов обект: { ...line, qty: line.qty + 1 }. Редовете, които не се сменят, остават същите обекти, и това е наред.
По-точно, за любопитните
Object.is. Като ===, с две разлики: Object.is(NaN, NaN) е true, а Object.is(0, -0) е false. За масиви и обекти и двете сравняват референции.
Вложени обекти. Всяко ниво от пътя до промяната е ново: { ...order, address: { ...order.address, city: 'Варна' } }. Нивата встрани остават същите.
structuredClone(cart) прави дълбоко копие: и масивът, и всеки обект в него. Работи, но копира всичко при всяка промяна, а функции не може да копира. За един ред map и ... са по-евтини.
Immer. Библиотеката Immer позволява да пишеш като с мутация (draft[0].qty++), а тя прави новите обекти вместо теб. В пясъчника я няма.
Работи и във Vue. Новият масив е правилен и във Vue: cart.value = cart.value.filter(...). Обратното не важи: мутацията, нормална във Vue, в React не се вижда.
Пробвай
„+“ и „−“
Направи more и less. „+“ вдига броя на реда с 1, а „−“ го сваля с 1. Когато броят е 1, „−“ маха реда. Сумата следва броя.
Конзола
Решение
App.jsximport { useState } from 'react';
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function App() {
const [cart, setCart] = useState([
{ id: 1, name: 'Бял хляб', price: 1.2, qty: 2 },
{ id: 5, name: 'Прясно мляко', price: 1.6, qty: 1 },
{ id: 7, name: 'Сирене', price: 7.9, qty: 1 },
]);
function more(id) {
setCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line)));
}
function less(id) {
const line = cart.find((l) => l.id === id);
if (line.qty === 1) {
remove(id);
} else {
setCart(cart.map((l) => (l.id === id ? { ...l, qty: l.qty - 1 } : l)));
}
}
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h2>Кошницата</h2>
<ul>
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => less(line.id)} aria-label={`По-малко ${line.name}`}>−</button>
<button type="button" onClick={() => more(line.id)} aria-label={`Още ${line.name}`}>+</button>
<button type="button" onClick={() => remove(line.id)} aria-label={`Махни ${line.name}`}>
Махни
</button>
</li>
))}
</ul>
<p>Общо: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
more е setCart(cart.map((line) => (line.id === id ? { ...line, qty: line.qty + 1 } : line))). less намира реда и при qty === 1 вика remove, а иначе прави същия map с - 1. Ако напишеш line.qty++ и setCart(cart), React не рисува: масивът и редът са същите. Без проверката за 1 редът стига до „0 ד и остава в кошницата.
Упражнение
Каталог и кошница
Каталогът има четири продукта с бутон „Добави“ (името за екранния четец: „Добави Бял хляб“). Направи add и remove. Първият клик на „Добави“ прави ред с брой 1, а следващите вдигат броя на същия ред, вместо да правят нов. „Махни“ маха реда. Когато кошницата е празна, пише „Кошницата е празна“.
Конзола
Решение
App.jsximport { useState } from 'react';
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
const products = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
{ id: 10, name: 'Ябълки', price: 2.1 },
];
export default function App() {
const [cart, setCart] = useState([]);
function add(product) {
const found = cart.find((line) => line.id === product.id);
if (found) {
setCart(cart.map((line) => (line.id === product.id ? { ...line, qty: line.qty + 1 } : line)));
} else {
setCart([...cart, { ...product, qty: 1 }]);
}
}
function remove(id) {
setCart(cart.filter((line) => line.id !== id));
}
const total = cart.reduce((sum, line) => sum + line.price * line.qty, 0);
return (
<main>
<h1>Магазинът</h1>
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}, {money(p.price)}
<button type="button" onClick={() => add(p)} aria-label={`Добави ${p.name}`}>
Добави
</button>
</li>
))}
</ul>
<h2>Кошницата</h2>
{cart.length === 0 && <p>Кошницата е празна</p>}
<ul aria-label="Кошницата">
{cart.map((line) => (
<li key={line.id}>
{line.name}: {line.qty} × {money(line.price)}
<button type="button" onClick={() => remove(line.id)} aria-label={`Махни ${line.name}`}>
Махни
</button>
</li>
))}
</ul>
<p>Общо: {money(total)}</p>
</main>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 8px 16px; }
h1 { font-size: 22px; }
h2 { font-size: 18px; margin: 16px 0 4px; }
ul { padding-left: 20px; }
li { margin: 6px 0; }
button { font: inherit; padding: 2px 10px; margin-left: 6px; }
add търси реда с find. Ако го има: map с нов обект за него. Ако го няма: setCart([...cart, { ...product, qty: 1 }]). { ...product, qty: 1 } е нов обект и продуктът в каталога не се пипа. remove е filter. С cart.push и setCart(cart) кошницата остава празна, а с [...cart, …] при всеки клик се появява нов ред за същия продукт и два реда имат един и същ key.Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Първият компонент · Списъци и ключове · Условия: празно, грешка, зарежда · Състояние
- Продължава в: Изчислено състояние · Вдигане на състоянието · Общо състояние · Витрината на магазина
- Задачи по темата: Състояние