Списъци и ключове
Накратко: Списъкът в React се прави с map от масив към компоненти, а key казва на React кой стар ред е кой нов; случайното число е лош ключ, а индексът е лош, щом списъкът се мени.
Проблем
Махнах хляба, а млякото стана 3
Купувачът има в кошницата хляб, мляко и сирене. Пише 3 при хляба и 2 при млякото, а после маха хляба. Пробвай: смени двете числа и натисни „Махни“ при хляба.
Конзола
Хлябът е махнат, но млякото вече е 3, а сиренето е 2. В конзолата няма грешка. Данните са верни: махнат е точно хлябът. Объркани са само полетата. Причината е един атрибут на ред 20 в App.jsx: какво казва кодът на библиотеката за всеки ред от списъка.
Идеята
Списък в компонент
В DOM модула списъкът се правеше от масив с map и createElement. В компонент е същата идея, но всеки елемент от масива става част от разметката:
<ul> {items.map((p) => ( <li key={p.id}> <ProductCard product={p} /> </li> ))}</ul>map връща масив от JSX елементи, а React ги рисува един след друг. Всеки елемент от масива има атрибут key: ключ, по който React го разпознава.
Когато данните се сменят, компонентът връща нов списък. Библиотеката не трие стария, за да го нарисува наново. Тя сравнява стария и новия и сменя в страницата само разликата. Затова фокусът и написаното в полетата остават, а не се губят като при render() от DOM модула.
За да сравни, тя трябва да знае кой стар ред на кой нов отговаря. Това казва ключът.
Идеята
Сдвояване по ключ
Библиотеката сдвоява старите и новите редове по ключ:
- Същият ключ е същият ред. Елементът в страницата остава, а библиотеката сменя в него само каквото е различно в данните: името, цената.
- Ключ, който го няма в новия списък, е махнат ред. Елементът му се маха от страницата.
- Нов ключ е нов ред. Прави се нов елемент.
Елементът, който остава, пази всичко свое: написаното в <input>, фокуса, отвореното падащо меню. Тези неща не са в данните, а в самия елемент. Затова е важно той да остане при своя продукт.
В примера ключът е индексът: мястото в масива. След „Махни“ млякото е на място 0. Старият ред с ключ 0 беше на хляба. Библиотеката го запазва, сменя името му на „Прясно мляко“ и оставя полето с 3.
Виж го
Виж сдвояването
Отляво е страницата преди „Махни“, отдясно е новият списък от компонента. Гледай кой ред се маха.
// key = мястото в списъка{items.map((p, index) => ( <li key={index}> <ProductCard product={p} /> </li>))}
// key = id на продукта{items.map((p) => ( <li key={p.id}> <ProductCard product={p} /> </li>))}Стъпка по стъпка, с думи
- Три реда в кошницата, а ключът е мястото: 0, 1, 2. Купувачът е написал 3, 2 и 1 в полетата. Написаното живее в елемента <input>, а не в данните.
- „Махни Бял хляб“. В данните вече има два продукта и компонентът връща нов списък: ключ 0 е млякото, ключ 1 е сиренето.
- Библиотеката сдвоява стария и новия списък по ключ: 0 с 0, 1 с 1. Ключ 2 го няма в новия, затова се маха третият ред: този на сиренето.
- Запазените редове получават новите данни и заглавията им вече са мляко и сирене. Полетата са старите, с 3 и 2. Броят на хляба е при млякото.
- Същата кошница с ключ id. Ключовете са 1, 2, 3 и са на продукта, а не на мястото.
- Пак „Махни Бял хляб“. Новият списък е с ключове 2 и 3.
- Ключ 2 се сдвоява с 2, а 3 с 3, макар че са се преместили нагоре. Ключ 1 липсва и се маха редът на хляба, заедно с полето му.
- Всяко поле е останало при своя продукт: мляко 2, сирене 1. Ключът казва на библиотеката кой кой е, а не къде е.
С индекса се маха последният ред, а първите два сменят имената си. С id се маха редът на хляба, а другите два само се качват нагоре с полетата си. И в двата случая данните са едни и същи. Разликата е какво казва ключът: мястото или продукта.
Пробвай
Поправи ключа
Поправи кошницата така, че след „Махни“ всяко число да остане при своя продукт. Проверката пише 3 при хляба и 2 при млякото, маха хляба и гледа полетата на млякото и сиренето.
Конзола
Решение
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 2, name: 'Прясно мляко', price: 1.6 },
{ id: 3, name: 'Сирене', price: 7.9 },
];
export default function App() {
// Състоянието (следващият модул): списъкът, който бутоните сменят.
const [items, setItems] = useState(PRODUCTS);
const remove = (id) => setItems(items.filter((p) => p.id !== id));
return (
<main>
<h1>Кошницата</h1>
<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
<button type="button" aria-label={`Махни ${p.name}`} onClick={() => remove(p.id)}>Махни</button>
</li>
))}
</ul>
</main>
);
}
ProductCard.jsxconst money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Брой <input type="number" min="1" defaultValue="1" aria-label={`Брой: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
Ключът става id на продукта. Той е един и същ при всяко рисуване и е различен за всеки продукт. След „Махни“ липсва ключ 1, затова се маха редът на хляба, а редовете на млякото и сиренето остават с полетата си. Без ключ библиотеката пак сравнява по място и грешката остава.
Пробвай
Попълни
Как се казва атрибутът, по който библиотеката разпознава реда?
{items.map((p) => (
<li ={p.id}>
<ProductCard product={p} />
</li>
))}key: React го чете, за да сдвои редовете, и не го подава на компонента.
Идеята
Добрият ключ
Добрият ключ е стабилен и уникален: един и същ за един и същ продукт при всяко рисуване и различен за различните продукти. Най-добрият е id от данните: той идва от базата и не се мени.
- Индексът е лош ключ, ако списъкът се мени: махане, добавяне в началото, подреждане, филтър. Става, ако списъкът е само за четене и никога не мени реда си.
Math.random()е лош ключ винаги. При всяко рисуване ключът е нов, затова нито един стар ред няма двойка. Библиотеката маха всички редове и прави нови. Полетата губят написаното, а фокусът изчезва.- Два еднакви ключа объркват сдвояването. Тук ключът е името, а „Кисело мляко“ има в два размера. Натисни „Подреди по цена“ и брой редовете.
Конзола
Продуктите са четири, а редовете стават пет: едното кисело мляко е два пъти.
React казва това още при първото рисуване, с грешка в конзолата: „Encountered two children with the same key,Кисело мляко“. Ако забравиш ключа, грешката е „Each child in a list should have a unique "key" prop.“ Двете съобщения са от React в режим за разработка. В истинския сайт конзолата мълчи, а грешката в страницата остава.Пробвай
Намери грешката
При всяко рисуване наново всички полета „Брой“ се връщат на 1, а курсорът излиза от полето.
Избери реда с грешката.
Ред 3: key={Math.random()} е нов ключ при всяко рисуване. Нито един стар ред няма двойка и React прави всички наново. Ключът трябва да е p.id.
Идеята
Филтър и подредба
Каталогът показва само наличните продукти, подредени по цена. Това е изведен списък: не се пази отделно, а се прави от данните при всяко рисуване.
const visible = PRODUCTS .filter((p) => p.stock > 0) .sort((a, b) => a.price - b.price);Това е в тялото на компонента: всяко рисуване прави visible наново от PRODUCTS.
filterвръща нов масив само с наличните. Оригиналът остава цял.sortподрежда масива на място. Тук е безопасно, защото е следfilter, а той вече е дал копие. Без филтър първо се прави копие:[...PRODUCTS].sort(…).- Ключът остава
p.id. При подреждане редовете сменят местата си, а индексът би сменил ключа на всеки от тях.
Пробвай
Само наличните
Покажи само продуктите в наличност (stock > 0), подредени по цена от най-евтиния. Над списъка напиши „Налични: 3 от 5“.
Конзола
Решение
App.jsximport ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 3, name: 'Сирене', price: 7.9, stock: 4 },
{ id: 4, name: 'Баница', price: 2.5, stock: 0 },
{ id: 1, name: 'Бял хляб', price: 1.2, stock: 10 },
{ id: 5, name: 'Краставици', price: 2.2, stock: 0 },
{ id: 2, name: 'Прясно мляко', price: 1.6, stock: 6 },
];
export default function App() {
const visible = PRODUCTS.filter((p) => p.stock > 0).sort((a, b) => a.price - b.price);
return (
<main>
<h1>Каталогът</h1>
<p>Налични: {visible.length} от {PRODUCTS.length}</p>
<ul>
{visible.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
</main>
);
}
ProductCard.jsxconst money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Брой <input type="number" min="1" defaultValue="1" aria-label={`Брой: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
PRODUCTS.filter((p) => p.stock > 0) маха баницата и краставиците, а .sort((a, b) => a.price - b.price) нарежда хляба, млякото и сиренето. Числата идват от двата масива: visible.length и PRODUCTS.length. Проверката гледа реда на заглавията. Ключа тя не вижда: в този списък нищо не се мени след рисуването. Той е важен, щом нещо се мени, както в упражнението.
По-точно, за любопитните
key не е prop. Библиотеката взима ключа за себе си и не го подава на компонента. props.key в ProductCard е undefined, а React пише грешка „key is not a prop“. Ако компонентът има нужда от id, подай го отделно: product вече го съдържа.
Ключ за нулиране. Нов ключ значи нов елемент. Затова сменен ключ е начин да нулираш компонент: формуляр с ключ id на продукта започва празен при всеки нов продукт.
Няколко елемента на ред. Ако един продукт е два елемента (<dt> и <dd>), ключът е на обвивката: <Fragment key={p.id}>…</Fragment>. Краткият запис <>…</> не приема ключ.
Класовите компоненти. Старият код на React има компоненти като класове (class ProductList extends Component). Ключовете в тях работят по същия начин.
toSorted. products.toSorted((a, b) => a.price - b.price) връща подредено копие и не пипа оригинала. Той е в браузърите от 2023 г.
Защо полето е „неконтролирано“. Тук числото в полето е само в DOM-а. В модула „Състояние“ полето става контролирано: числото е в данните и ги следва. Тогава ключът пак е важен: за фокуса, за курсора и за състоянието, което компонентът пази в себе си.
Упражнение
Кошницата
Състоянието и бутонът „Подреди по цена“ са готови. Направи реда за всеки продукт: ProductCard и бутон „Махни“ с име за екранния четец „Махни Бял хляб“. Проверката пише бройки, подрежда, маха хляба и гледа, че всяко число остава при своя продукт.
Конзола
Решение
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 3, name: 'Сирене', price: 7.9 },
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 2, name: 'Прясно мляко', price: 1.6 },
];
export default function App() {
// Състоянието (следващият модул): списъкът, който бутоните сменят.
const [items, setItems] = useState(PRODUCTS);
const remove = (id) => setItems(items.filter((p) => p.id !== id));
const sortByPrice = () => setItems([...items].sort((a, b) => a.price - b.price));
return (
<main>
<h1>Кошницата ({items.length})</h1>
<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
<button type="button" aria-label={`Махни ${p.name}`} onClick={() => remove(p.id)}>Махни</button>
</li>
))}
</ul>
<button type="button" onClick={sortByPrice}>Подреди по цена</button>
</main>
);
}
ProductCard.jsxconst money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Брой <input type="number" min="1" defaultValue="1" aria-label={`Брой: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
Редът е <li> с ключ p.id, ProductCard и бутон. Бутонът вика remove(p.id), а името му за екранния четец идва от aria-label. С индекс за ключ подреждането мести числата: сиренето беше първо, затова 4 остава на първия ред, вече на хляба. С Math.random() всяко подреждане връща полетата на 1.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Събития · Списък от данни · Първият компонент · Props: данни отгоре
- Продължава в: Условия: празно, грешка, зарежда · Състояние · Обновяване на обекти и масиви · Изчислено състояние
- Задачи по темата: Компоненти