Кнопка добавила товар, список корзины обновился, а счётчик остался прежним? Храните корзину в общем родительском компоненте. Карточкам передавайте количество и обработчики; счётчик и сумму вычисляйте из того же массива при рендере.

Соберём учебный магазин с блокнотом и карандашом. Два клика по блокноту дадут одну строку корзины с количеством два; счётчик покажет две единицы. Сумма — 700 ₽. После удаления блокнота карточка снова покажет ноль.

Нужен проект React, в котором уже работают JSX и основной компонент App. Замените его App.jsx и добавьте рядом cart-model.mjs. Понадобятся основы компонентов и useState; если пока разбираетесь с JSX, начните с введения в React.

Товары и цены вымышлены. После перезагрузки корзина сбрасывается, а оформления заказа здесь нет: сейчас тренируем согласованное состояние интерфейса.

Поднимите корзину до общего родителя

App хранит корзину. Карточка получает quantity и onAdd; список — items и onRemove; счётчик — вычисленные count и totalCents.

Карточка товара, список корзины и счётчик используют одни данные. Их ближайший общий родитель — 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 шт.2700 ₽
Добавить карандашБлокнот, 2 шт.; карандаш, 1 шт.3775 ₽
Удалить блокнотКарандаш, 1 шт.175 ₽
Удалить карандашКорзина пуста00 ₽

Проверьте клики и границы

Пройдите действия из таблицы. После каждого клика сравните количество в карточке, строку корзины и итоговый счётчик. Все они должны изменяться вместе. На 50 блокнотах кнопка добавления станет недоступна и появится объяснение лимита. После удаления ограничение снимется.

Если карточка показывает количество, которого нет в корзине, проверьте, не появился ли внутри неё ещё один useState для этого же количества. Если счётчик после удаления показывает старое число, найдите его отдельное состояние и замените вычислением из cart.

Для небольшой доработки добавьте рядом со счётчиком число разных позиций. Подсказка: оно равно cart.length. Два блокнота и один карандаш должны дать надпись «Позиций: 2» при счётчике трёх единиц.

Добавьте действие без второго состояния

Самостоятельно сделайте кнопку «Очистить корзину» в App. После клика массив должен стать пустым; список, счётчик и количества в карточках обновятся из этого массива. Не храните дополнительный признак «корзина очищена».

Для системной практики компонентов и состояния посмотрите курс «React. Разработка сложных клиентских приложений» HTML Academy.

Какое значение нужно хранить, чтобы список и обе карточки оставались согласованными? Что покажут cart.length и сумма quantity, если в корзине пять одинаковых блокнотов?


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники