Кнопка добавила товар, список корзины обновился, а счётчик остался прежним? Храните корзину в общем родительском компоненте. Карточкам передавайте количество и обработчики; счётчик и сумму вычисляйте из того же массива при рендере.
Соберём учебный магазин с блокнотом и карандашом. Два клика по блокноту дадут одну строку корзины с количеством два; счётчик покажет две единицы. Сумма — 700 ₽. После удаления блокнота карточка снова покажет ноль.
Нужен проект React, в котором уже работают JSX и основной компонент App. Замените его App.jsx и добавьте рядом cart-model.mjs. Понадобятся основы компонентов и useState; если пока разбираетесь с JSX, начните с введения в React.
Товары и цены вымышлены. После перезагрузки корзина сбрасывается, а оформления заказа здесь нет: сейчас тренируем согласованное состояние интерфейса.
Поднимите корзину до общего родителя
Карточка товара, список корзины и счётчик используют одни данные. Их ближайший общий родитель — App. В нём и будет единственный useState с массивом выбранных товаров. Такой перенос состояния называют поднятием; его разбирает документация React.
Для одной позиции достаточно идентификатора товара и количества:
const cartItem = { productId: 'notebook', quantity: 2 };
Название и цену возьмём из отдельного каталога. Если скопировать их в каждую позицию корзины, придётся согласовывать несколько копий при изменении товара. Для нашего небольшого каталога поиск через find подойдёт; при росте данных можно заранее построить таблицу товаров по идентификаторам.
Карточка получает quantity через свойства компонента, или пропсы. При клике она вызывает onAdd(product.id). Обработчик в App обновляет массив, после чего React рендерит компоненты с новыми данными. Отдельного счётчика внутри карточки нет.
Добавьте товар, сохранив предыдущее состояние
Создайте cart-model.mjs. В нём лежат каталог и функции, которые не зависят от React. Товары имеют разные идентификаторы. Цены записаны целым числом копеек, поэтому блокнот за 350 ₽ получает priceCents: 35000. Учебное ограничение — 50 единиц одного товара; это наше правило примера.
export const PRODUCTS = [
{ id: 'notebook', title: 'Блокнот', priceCents: 35000 },
{ id: 'pencil', title: 'Карандаш', priceCents: 7500 },
];
export const MAX_QUANTITY = 50;
export function addProduct(cart, productId) {
if (!PRODUCTS.some(product => product.id === productId)) {
return cart;
}
const existing = cart.find(item => item.productId === productId);
if (!existing) {
return [...cart, { productId, quantity: 1 }];
}
if (existing.quantity >= MAX_QUANTITY) {
return cart;
}
return cart.map(item => item.productId === productId
? { ...item, quantity: item.quantity + 1 }
: item);
}
export function removeProduct(cart, productId) {
return cart.filter(item => item.productId !== productId);
}
export function getSummary(cart) {
const count = cart.reduce((sum, item) => sum + item.quantity, 0);
const totalCents = cart.reduce((sum, item) => {
const product = PRODUCTS.find(product => product.id === item.productId);
return sum + product.priceCents * item.quantity;
}, 0);
return { count, totalCents };
}
export function formatMoney(cents) {
return (cents / 100).toLocaleString('ru-RU', {
style: 'currency', currency: 'RUB',
});
}
addProduct сначала проверяет, есть ли товар в каталоге. Новый товар добавляется через копию массива. Если позиция уже существует, map создаёт новый массив и новый объект именно для изменившейся позиции. Остальные объекты можно сохранить. Их поля мы не меняем.
Не используйте push для массива из состояния и не увеличивайте item.quantity на существующем объекте. Предыдущее состояние должно остаться прежним. Правило и варианты обновления массивов есть в документации React.
removeProduct удаляет всю позицию вместе с её количеством. Кнопки «Уменьшить на один» здесь нет: даже если блокнотов было пять, удаление уберёт все пять.
Функции принимают внутреннюю корзину, которую создаёт addProduct: с известными идентификаторами и положительным целым количеством. Данные из API или хранилища сначала нужно проверить на соответствие этому формату. Например, неизвестный идентификатор в импортированном массиве сломает поиск товара в getSummary.
Передайте карточкам данные и события
Теперь замените App.jsx целиком. Все компоненты примера определены в этом файле; зависимости из cart-model.mjs импортированы в начале.
import { useState } from 'react';
import {
PRODUCTS, MAX_QUANTITY, addProduct,
removeProduct, getSummary, formatMoney,
} from './cart-model.mjs';
function ProductCard({ product, quantity, onAdd }) {
return (
<article>
<h3>{product.title}</h3>
<p>{formatMoney(product.priceCents)}</p>
<p>В корзине: {quantity}</p>
<button
type="button"
disabled={quantity >= MAX_QUANTITY}
onClick={() => onAdd(product.id)}
>
Добавить {product.title.toLowerCase()}
</button>
{quantity >= MAX_QUANTITY && <p>Лимит: 50 единиц одного товара.</p>}
</article>
);
}
function Cart({ items, onRemove }) {
if (items.length === 0) {
return <p>Корзина пуста. Добавьте товар из каталога.</p>;
}
return (
<ul>
{items.map(item => {
const product = PRODUCTS.find(product => product.id === item.productId);
return (
<li key={item.productId}>
{product.title}: {item.quantity} шт.,{' '}
{formatMoney(product.priceCents * item.quantity)}{' '}
<button type="button" onClick={() => onRemove(item.productId)}>
Удалить {product.title.toLowerCase()}
</button>
</li>
);
})}
</ul>
);
}
function CartSummary({ count, totalCents }) {
return (
<p aria-live="polite">
Единиц товара: {count}. Сумма: {formatMoney(totalCents)}.
</p>
);
}
export default function App() {
const [cart, setCart] = useState([]);
const summary = getSummary(cart);
function handleAdd(productId) {
setCart(current => addProduct(current, productId));
}
function handleRemove(productId) {
setCart(current => removeProduct(current, productId));
}
return (
<main>
<h1>Учебный магазин</h1>
<CartSummary {...summary} />
<section aria-labelledby="catalog-heading">
<h2 id="catalog-heading">Каталог</h2>
{PRODUCTS.map(product => (
<ProductCard
key={product.id}
product={product}
quantity={cart.find(item => item.productId === product.id)?.quantity ?? 0}
onAdd={handleAdd}
/>
))}
</section>
<section aria-labelledby="cart-heading">
<h2 id="cart-heading">Корзина</h2>
<Cart items={cart} onRemove={handleRemove} />
</section>
</main>
);
}
У setCart стоит функция обновления: current => addProduct(current, productId). React передаёт ей состояние с учётом предыдущих обновлений в очереди. Это нужно, когда новое значение зависит от предыдущего. Если отправить несколько обновлений подряд на основе одного снимка cart, часть изменений можно потерять. Порядок разбирает документация об очереди обновлений.
Обработчики остаются в родителе, а карточка сообщает ему, какой товар выбран. При удалении строка корзины тоже сообщает о действии родителю. Для каждой строки используем стабильный key={item.productId}: он останется тем же, когда соседнюю строку удалят и положение этой строки поменяется.
Вычислите счётчик и сумму из корзины
Перед следующим абзацем предскажите cart.length и сумму quantity для двух блокнотов и одного карандаша. Объясните, какое из этих значений нужно показывать в счётчике единиц.
Два блокнота и один карандаш займут две строки, поэтому длина массива будет равна двум. А единиц три. Их число даёт сумма quantity. Для самопроверки выполните эти клики в примере: в списке появятся две строки, а счётчик покажет три единицы.
getSummary(cart) вызывается при рендере App. Дополнительные useState для суммы и счётчика, а также useEffect для их синхронизации здесь избыточны: оба значения уже однозначно выводятся из корзины. Это устраняет отдельную задачу синхронизации. Этот подход объясняют рекомендации React по структуре состояния.
Считаем в копейках. Для отображения formatMoney переводит сумму в рубли и добавляет знак валюты. Для двух товаров и лимита 50 значения остаются в диапазоне точных целых чисел JavaScript. Это учебный подсчёт на клиенте: реальный сервер при оформлении заказа должен самостоятельно проверить товары, доступное количество и актуальные цены.
| Действие | Строки корзины | Счётчик | Сумма |
|---|---|---|---|
| Добавить блокнот дважды | Блокнот, 2 шт. | 2 | 700 ₽ |
| Добавить карандаш | Блокнот, 2 шт.; карандаш, 1 шт. | 3 | 775 ₽ |
| Удалить блокнот | Карандаш, 1 шт. | 1 | 75 ₽ |
| Удалить карандаш | Корзина пуста | 0 | 0 ₽ |
Проверьте клики и границы
Пройдите действия из таблицы. После каждого клика сравните количество в карточке, строку корзины и итоговый счётчик. Все они должны изменяться вместе. На 50 блокнотах кнопка добавления станет недоступна и появится объяснение лимита. После удаления ограничение снимется.
Если карточка показывает количество, которого нет в корзине, проверьте, не появился ли внутри неё ещё один useState для этого же количества. Если счётчик после удаления показывает старое число, найдите его отдельное состояние и замените вычислением из cart.
Для небольшой доработки добавьте рядом со счётчиком число разных позиций. Подсказка: оно равно cart.length. Два блокнота и один карандаш должны дать надпись «Позиций: 2» при счётчике трёх единиц.
Добавьте действие без второго состояния
Самостоятельно сделайте кнопку «Очистить корзину» в App. После клика массив должен стать пустым; список, счётчик и количества в карточках обновятся из этого массива. Не храните дополнительный признак «корзина очищена».
Для системной практики компонентов и состояния посмотрите курс «React. Разработка сложных клиентских приложений» HTML Academy.
Какое значение нужно хранить, чтобы список и обе карточки оставались согласованными? Что покажут cart.length и сумма quantity, если в корзине пять одинаковых блокнотов?