Skip to content
aviral gupta

// B3.6 · ~45 min · Beginner

Build: a shopping cart

In this build you make a shopping cart: add products, change quantities, remove items and show a total in euros, all with immutable updates.

Lesson 6 of 6 in B3 Adding interactivity

End of the module

You will be able to

  • Add a product to the cart as a new item or by raising its quantity
  • Change quantities with map, remove items with filter, and drop an item at 0
  • Compute and format totals from cents while rendering, and use an updater to add twice
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.5. Coffee has a count of 1, Rice 2. The - handler maps, then filters. You click - on Coffee. What does the list show?

    function Pantry() {
      const [items, setItems] = useState([
        { id: 0, name: 'Coffee', count: 1 },
        { id: 1, name: 'Rice', count: 2 }
      ]);
    
      function handleDecrease(itemId) {
        setItems(items
          .map(item => item.id === itemId ? { ...item, count: item.count - 1 } : item)
          .filter(item => item.count > 0)
        );
      }
    
      return (
        <ul>
          {items.map(item => (
            <li key={item.id}>
              {item.name} ({item.count})
              <button onClick={() => handleDecrease(item.id)}>-</button>
            </li>
          ))}
        </ul>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. handleAdd always appends a copy of the product with quantity 1. You click Bread twice. How many <li> does the cart show?

    const bread = { id: 1, name: 'Bread', priceCents: 249 };
    const apple = { id: 0, name: 'Apple', priceCents: 50 };
    
    function Shop() {
      const [cart, setCart] = useState([]);
    
      function handleAdd(product) {
        setCart([...cart, { ...product, quantity: 1 }]);
      }
    
      return (
        <>
          <button onClick={() => handleAdd(bread)}>Bread</button>
          <button onClick={() => handleAdd(apple)}>Apple</button>
          <ul>
            {cart.map(item => (
              <li key={item.id}>{item.name} × {item.quantity}</li>
            ))}
          </ul>
        </>
      );
    }
  3. Practice · Activity 3 of 7

    Fill in the number method, so that formatPrice(50) gives €0.50, formatPrice(249) €2.49 and formatPrice(1700) €17.00.

    function formatPrice(cents) {
      return '€' + (cents / 100).____(2);
    }
    (cents / 100).(2)
  4. Practice · Activity 4 of 7

    Match each cart action with the update it needs.

  5. Practice · Activity 5 of 7

    What does this print?

    const cart = [
      { name: 'Bread', priceCents: 249, quantity: 2 },
      { name: 'Apple', priceCents: 50, quantity: 3 }
    ];
    const totalCents = cart.reduce(
      (sum, item) => sum + item.priceCents * item.quantity,
      0
    );
    console.log('€' + (totalCents / 100).toFixed(2));
  6. Brain teaser · Activity 6 of 7

    Brain teaser. handleAdd merges correctly, and an Add 2 breads button calls it twice in one click. You click Bread, then Add 2 breads. What does the cart show?

    const bread = { id: 1, name: 'Bread', priceCents: 249 };
    const apple = { id: 0, name: 'Apple', priceCents: 50 };
    
    function Shop() {
      const [cart, setCart] = useState([]);
    
      function handleAdd(product) {
        if (cart.some(item => item.id === product.id)) {
          setCart(cart.map(item =>
            item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
          ));
        } else {
          setCart([...cart, { ...product, quantity: 1 }]);
        }
      }
    
      function handleAddTwo() {
        handleAdd(bread);
        handleAdd(bread);
      }
    
      return (
        <>
          <button onClick={() => handleAdd(bread)}>Bread</button>
          <button onClick={() => handleAdd(apple)}>Apple</button>
          <button onClick={handleAddTwo}>Add 2 breads</button>
          <ul>
            {cart.map(item => (
              <li key={item.id}>{item.name} × {item.quantity}</li>
            ))}
          </ul>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a cart heading. In main.jsx, write a CartSummary component with a cart state of two items: Apple with quantity 3 and Bread with quantity 1. Show the number of items, counting quantities, in an <h2> such as Cart (4 items), with item for exactly 1. Add a Clear cart button that empties the cart. Do not keep the count in state. Run it and click Clear cart.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A pantry list: + and - with map and filter

Before the cart, the core of its quantity buttons, as in react.dev's shopping cart challenges, in this lesson's own code. Pantry keeps an array of items with a count. + maps to a new array in which only the clicked item is a new object. - maps the same way, then filters out items whose count has reached 0, so the list never shows 0. A run only mounts, so timers at the bottom of main.jsx click + on Rice, then - on Coffee, and log how many items are left.

main.jsx

import { useState } from 'react';

const initialItems = [
  { id: 0, name: 'Coffee', count: 1 },
  { id: 1, name: 'Rice', count: 2 },
  { id: 2, name: 'Tea', count: 1 },
];

export default function Pantry() {
  const [items, setItems] = useState(initialItems);

  function handleIncrease(itemId) {
    setItems(items.map(item => {
      if (item.id === itemId) {
        return { ...item, count: item.count + 1 };
      } else {
        return item;
      }
    }));
  }

  function handleDecrease(itemId) {
    const nextItems = items
      .map(item => {
        if (item.id === itemId) {
          return { ...item, count: item.count - 1 };
        } else {
          return item;
        }
      })
      .filter(item => item.count > 0);
    setItems(nextItems);
  }

  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          {item.name} ({item.count}){' '}
          <button onClick={() => handleIncrease(item.id)}>+</button>
          <button onClick={() => handleDecrease(item.id)}>-</button>
        </li>
      ))}
    </ul>
  );
}

// A run only mounts. Act like a user: + on Rice, then - on Coffee.
setTimeout(() => {
  document.querySelectorAll('li')[1].querySelector('button').click();
  setTimeout(() => {
    document.querySelectorAll('li')[0].querySelectorAll('button')[1].click();
    setTimeout(() => {
      console.log('items left:', document.querySelectorAll('li').length);
    }, 50);
  }, 50);
}, 50);

Run it with

npm run dev

Output

items left: 2
<!-- #root -->
<div id="root"><ul><li>Rice (3) <button>+</button><button>-</button></li><li>Tea (1) <button>+</button><button>-</button></li></ul></div>
  • + on Rice made a new Rice object with count 3; Coffee and Tea stayed the same objects.
  • - on Coffee made its count 0, and filter dropped it, so two items are left.
  • initialItems was never changed: every update made new arrays and new objects.
  • The exercises turn this into a cart: products from data.js, quantities, a Remove button and a total.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 3

Add products to the cart

The shop lists three products from data.js, each with an Add to cart button, and an empty cart. handleAdd pushes into the state array, so nothing appears. Rewrite it without mutation. If the cart has no item with the product's id, append { ...product, quantity: 1 }. If it has one, raise that item's quantity by 1 with map, so Bread added twice shows as Bread × 2. Leave data.js unchanged.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    cart.some(item => item.id === product.id) tells you whether the product is already in the cart.

  2. Hint 2

    New product: setCart([...cart, { ...product, quantity: 1 }]).

  3. Hint 3

    Product already there: setCart(cart.map(item => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item)).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';
import { products } from './data.js';

function formatPrice(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function ProductList({ onAdd }) {
  return (
    <ul aria-label="Products">
      {products.map(product => (
        <li key={product.id}>
          {product.name} {formatPrice(product.priceCents)}{' '}
          <button onClick={() => onAdd(product)}>Add to cart</button>
        </li>
      ))}
    </ul>
  );
}

function Cart({ items }) {
  if (items.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  return (
    <ul aria-label="Cart">
      {items.map(item => (
        <li key={item.id}>
          {item.name} × {item.quantity}
        </li>
      ))}
    </ul>
  );
}

export default function ShoppingCart() {
  const [cart, setCart] = useState([]);

  function handleAdd(product) {
    if (cart.some(item => item.id === product.id)) {
      setCart(cart.map(item => {
        if (item.id === product.id) {
          return { ...item, quantity: item.quantity + 1 };
        } else {
          return item;
        }
      }));
    } else {
      setCart([...cart, { ...product, quantity: 1 }]);
    }
  }

  return (
    <>
      <h1>Shop</h1>
      <ProductList onAdd={handleAdd} />
      <h2>Cart</h2>
      <Cart items={cart} />
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function formatPrice(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function ProductList({ onAdd }) {
  return (
    <ul aria-label="Products">
      {products.map(product => (
        <li key={product.id}>
          {product.name} {formatPrice(product.priceCents)}{' '}
          <button onClick={() => onAdd(product)}>Add to cart</button>
        </li>
      ))}
    </ul>
  );
}

function Cart({ items }) {
  if (items.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  return (
    <ul aria-label="Cart">
      {items.map(item => (
        <li key={item.id}>
          {item.name} × {item.quantity}
        </li>
      ))}
    </ul>
  );
}

export default function ShoppingCart() {
  const [cart, setCart] = useState([]);

  // TODO: Add to cart shows nothing yet.
  function handleAdd(product) {
    cart.push(product);
  }

  return (
    <>
      <h1>Shop</h1>
      <ProductList onAdd={handleAdd} />
      <h2>Cart</h2>
      <Cart items={cart} />
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ShoppingCart from './main.jsx';
import {products as data} from './data.js';

const products = () => screen.getByRole('list', {name: 'Products'});
const productRow = (name) => within(products()).getAllByRole('listitem').find((li) => li.textContent.startsWith(name + ' '));
const add = (user, name, label = 'Add to cart') => user.click(within(productRow(name)).getByRole('button', {name: label}));
// Each cart line's text without its buttons, e.g. "Bread × 2".
const cartLines = () => (screen.queryByRole('list', {name: 'Cart'})
  ? within(screen.getByRole('list', {name: 'Cart'})).getAllByRole('listitem').map((li) =>
      [...li.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim())
  : []);
const cartRow = (name) => within(screen.getByRole('list', {name: 'Cart'})).getAllByRole('listitem').find((li) => li.textContent.startsWith(name + ' '));
const press = (user, name, label) => user.click(within(cartRow(name)).getByRole('button', {name: label}));

test('The cart starts empty', () => {
  render(<ShoppingCart />);
  assert.ok(screen.queryByText('Your cart is empty.'), 'the page shows no "Your cart is empty." message');
});

test('Add to cart puts the product in the cart once', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  assert.deepEqual(cartLines(), ['Bread × 1'], 'after adding Bread the cart lines are ' + JSON.stringify(cartLines()));
  assert.equal(screen.queryByText('Your cart is empty.'), null, 'the empty-cart message is still shown');
});

test('Adding the same product again raises its quantity', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Apple');
  await add(user, 'Bread');
  assert.deepEqual(cartLines(), ['Bread × 2', 'Apple × 1'], 'after Bread, Apple, Bread the cart lines are ' + JSON.stringify(cartLines()));
});

test('The products in data.js are not changed', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Cheese');
  await add(user, 'Cheese');
  assert.equal('quantity' in data[2], false, 'after adding, products[2] in data.js is ' + JSON.stringify(data[2]));
});

data.js

export const products = [
  { id: 0, name: 'Apple', priceCents: 50 },
  { id: 1, name: 'Bread', priceCents: 249 },
  { id: 2, name: 'Cheese', priceCents: 425 }
];

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 3

Change quantities and remove items

Adding works now, and every cart line has +, - and Remove buttons. Write the three handlers without mutation. + raises the quantity of that item by 1. - lowers it by 1, and removes the item when its quantity reaches 0, so the cart never shows 0. Remove takes the item out at any quantity. When the last item goes, the cart shows Your cart is empty. again.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    + is the same map as adding a product that is already in the cart, but by id.

  2. Hint 2

    -: map to quantity - 1 for that id, then .filter(item => item.quantity > 0), then setCart.

  3. Hint 3

    Remove: setCart(cart.filter(item => item.id !== productId)).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';
import { products } from './data.js';

function formatPrice(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function ProductList({ onAdd }) {
  return (
    <ul aria-label="Products">
      {products.map(product => (
        <li key={product.id}>
          {product.name} {formatPrice(product.priceCents)}{' '}
          <button onClick={() => onAdd(product)}>Add to cart</button>
        </li>
      ))}
    </ul>
  );
}

function Cart({ items, onIncrease, onDecrease, onRemove }) {
  if (items.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  return (
    <ul aria-label="Cart">
      {items.map(item => (
        <li key={item.id}>
          {item.name} × {item.quantity}{' '}
          <button onClick={() => onIncrease(item.id)}>+</button>
          <button onClick={() => onDecrease(item.id)}>-</button>
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

export default function ShoppingCart() {
  const [cart, setCart] = useState([]);

  function handleAdd(product) {
    if (cart.some(item => item.id === product.id)) {
      setCart(cart.map(item => {
        if (item.id === product.id) {
          return { ...item, quantity: item.quantity + 1 };
        } else {
          return item;
        }
      }));
    } else {
      setCart([...cart, { ...product, quantity: 1 }]);
    }
  }

  function handleIncrease(productId) {
    setCart(cart.map(item => {
      if (item.id === productId) {
        return { ...item, quantity: item.quantity + 1 };
      } else {
        return item;
      }
    }));
  }

  function handleDecrease(productId) {
    const nextCart = cart
      .map(item => {
        if (item.id === productId) {
          return { ...item, quantity: item.quantity - 1 };
        } else {
          return item;
        }
      })
      .filter(item => item.quantity > 0);
    setCart(nextCart);
  }

  function handleRemove(productId) {
    setCart(cart.filter(item => item.id !== productId));
  }

  return (
    <>
      <h1>Shop</h1>
      <ProductList onAdd={handleAdd} />
      <h2>Cart</h2>
      <Cart
        items={cart}
        onIncrease={handleIncrease}
        onDecrease={handleDecrease}
        onRemove={handleRemove}
      />
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function formatPrice(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function ProductList({ onAdd }) {
  return (
    <ul aria-label="Products">
      {products.map(product => (
        <li key={product.id}>
          {product.name} {formatPrice(product.priceCents)}{' '}
          <button onClick={() => onAdd(product)}>Add to cart</button>
        </li>
      ))}
    </ul>
  );
}

function Cart({ items, onIncrease, onDecrease, onRemove }) {
  if (items.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  return (
    <ul aria-label="Cart">
      {items.map(item => (
        <li key={item.id}>
          {item.name} × {item.quantity}{' '}
          <button onClick={() => onIncrease(item.id)}>+</button>
          <button onClick={() => onDecrease(item.id)}>-</button>
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

export default function ShoppingCart() {
  const [cart, setCart] = useState([]);

  function handleAdd(product) {
    if (cart.some(item => item.id === product.id)) {
      setCart(cart.map(item => {
        if (item.id === product.id) {
          return { ...item, quantity: item.quantity + 1 };
        } else {
          return item;
        }
      }));
    } else {
      setCart([...cart, { ...product, quantity: 1 }]);
    }
  }

  // TODO: +, - and Remove do not change the cart yet.
  function handleIncrease(productId) {
    const item = cart.find(item => item.id === productId);
    item.quantity++;
  }

  function handleDecrease(productId) {
  }

  function handleRemove(productId) {
  }

  return (
    <>
      <h1>Shop</h1>
      <ProductList onAdd={handleAdd} />
      <h2>Cart</h2>
      <Cart
        items={cart}
        onIncrease={handleIncrease}
        onDecrease={handleDecrease}
        onRemove={handleRemove}
      />
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ShoppingCart from './main.jsx';

const products = () => screen.getByRole('list', {name: 'Products'});
const productRow = (name) => within(products()).getAllByRole('listitem').find((li) => li.textContent.startsWith(name + ' '));
const add = (user, name, label = 'Add to cart') => user.click(within(productRow(name)).getByRole('button', {name: label}));
// Each cart line's text without its buttons, e.g. "Bread × 2".
const cartLines = () => (screen.queryByRole('list', {name: 'Cart'})
  ? within(screen.getByRole('list', {name: 'Cart'})).getAllByRole('listitem').map((li) =>
      [...li.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim())
  : []);
const cartRow = (name) => within(screen.getByRole('list', {name: 'Cart'})).getAllByRole('listitem').find((li) => li.textContent.startsWith(name + ' '));
const press = (user, name, label) => user.click(within(cartRow(name)).getByRole('button', {name: label}));

test('+ raises only that quantity', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Apple');
  await press(user, 'Bread', '+');
  assert.deepEqual(cartLines(), ['Bread × 2', 'Apple × 1'], 'after + on Bread the cart lines are ' + JSON.stringify(cartLines()));
});

test('- lowers the quantity', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Bread');
  await press(user, 'Bread', '-');
  assert.deepEqual(cartLines(), ['Bread × 1'], 'after - on Bread × 2 the cart lines are ' + JSON.stringify(cartLines()));
});

test('- at quantity 1 removes the item', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Apple');
  await press(user, 'Apple', '-');
  assert.deepEqual(cartLines(), ['Bread × 1'], 'after - on Apple × 1 the cart lines are ' + JSON.stringify(cartLines()));
});

test('Remove takes out the item at any quantity', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  for (let i = 0; i < 3; i++) await add(user, 'Cheese');
  await press(user, 'Cheese', 'Remove');
  assert.deepEqual(cartLines(), [], 'after Remove the cart lines are ' + JSON.stringify(cartLines()));
  assert.ok(screen.queryByText('Your cart is empty.'), 'the empty-cart message is not back');
});

data.js

export const products = [
  { id: 0, name: 'Apple', priceCents: 50 },
  { id: 1, name: 'Bread', priceCents: 249 },
  { id: 2, name: 'Cheese', priceCents: 425 }
];

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 3 of 3

Prices, the total and Add 2

Finish the cart. Each line shows its price after the quantity, priceCents * quantity formatted with formatPrice, e.g. Bread × 2 €4.98. Below the list, a <p> shows Total: and the sum of all lines, e.g. Total: €5.75; compute it while rendering, not in state, and show it only when the cart has items. Each product also has an Add 2 button that calls onAdd twice; it adds only 1 so far. Make handleAdd use an updater function, setCart(c => …).

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    In Cart, after the empty check: const totalCents = items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);

  2. Hint 2

    Each line: {item.name} × {item.quantity}{' '}{formatPrice(item.priceCents * item.quantity)}{' '}, then the buttons; wrap the <ul> and <p>Total: {formatPrice(totalCents)}</p> in a Fragment.

  3. Hint 3

    In handleAdd, write setCart(c => { … }) and use c instead of cart inside; return the new array from both branches.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';
import { products } from './data.js';

function formatPrice(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function ProductList({ onAdd }) {
  return (
    <ul aria-label="Products">
      {products.map(product => (
        <li key={product.id}>
          {product.name} {formatPrice(product.priceCents)}{' '}
          <button onClick={() => onAdd(product)}>Add to cart</button>
          <button onClick={() => {
            onAdd(product);
            onAdd(product);
          }}>
            Add 2
          </button>
        </li>
      ))}
    </ul>
  );
}

function Cart({ items, onIncrease, onDecrease, onRemove }) {
  if (items.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  const totalCents = items.reduce(
    (sum, item) => sum + item.priceCents * item.quantity,
    0
  );

  return (
    <>
      <ul aria-label="Cart">
        {items.map(item => (
          <li key={item.id}>
            {item.name} × {item.quantity}{' '}
            {formatPrice(item.priceCents * item.quantity)}{' '}
            <button onClick={() => onIncrease(item.id)}>+</button>
            <button onClick={() => onDecrease(item.id)}>-</button>
            <button onClick={() => onRemove(item.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <p>Total: {formatPrice(totalCents)}</p>
    </>
  );
}

export default function ShoppingCart() {
  const [cart, setCart] = useState([]);

  function handleAdd(product) {
    setCart(c => {
      if (c.some(item => item.id === product.id)) {
        return c.map(item => {
          if (item.id === product.id) {
            return { ...item, quantity: item.quantity + 1 };
          } else {
            return item;
          }
        });
      } else {
        return [...c, { ...product, quantity: 1 }];
      }
    });
  }

  function handleIncrease(productId) {
    setCart(cart.map(item => {
      if (item.id === productId) {
        return { ...item, quantity: item.quantity + 1 };
      } else {
        return item;
      }
    }));
  }

  function handleDecrease(productId) {
    const nextCart = cart
      .map(item => {
        if (item.id === productId) {
          return { ...item, quantity: item.quantity - 1 };
        } else {
          return item;
        }
      })
      .filter(item => item.quantity > 0);
    setCart(nextCart);
  }

  function handleRemove(productId) {
    setCart(cart.filter(item => item.id !== productId));
  }

  return (
    <>
      <h1>Shop</h1>
      <ProductList onAdd={handleAdd} />
      <h2>Cart</h2>
      <Cart
        items={cart}
        onIncrease={handleIncrease}
        onDecrease={handleDecrease}
        onRemove={handleRemove}
      />
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';
import { products } from './data.js';

function formatPrice(cents) {
  return '€' + (cents / 100).toFixed(2);
}

function ProductList({ onAdd }) {
  return (
    <ul aria-label="Products">
      {products.map(product => (
        <li key={product.id}>
          {product.name} {formatPrice(product.priceCents)}{' '}
          <button onClick={() => onAdd(product)}>Add to cart</button>
          <button onClick={() => {
            onAdd(product);
            onAdd(product);
          }}>
            Add 2
          </button>
        </li>
      ))}
    </ul>
  );
}

function Cart({ items, onIncrease, onDecrease, onRemove }) {
  // TODO: show each line's price and the total.
  if (items.length === 0) {
    return <p>Your cart is empty.</p>;
  }

  return (
    <ul aria-label="Cart">
      {items.map(item => (
        <li key={item.id}>
          {item.name} × {item.quantity}{' '}
          <button onClick={() => onIncrease(item.id)}>+</button>
          <button onClick={() => onDecrease(item.id)}>-</button>
          <button onClick={() => onRemove(item.id)}>Remove</button>
        </li>
      ))}
    </ul>
  );
}

export default function ShoppingCart() {
  const [cart, setCart] = useState([]);

  function handleAdd(product) {
    if (cart.some(item => item.id === product.id)) {
      setCart(cart.map(item => {
        if (item.id === product.id) {
          return { ...item, quantity: item.quantity + 1 };
        } else {
          return item;
        }
      }));
    } else {
      setCart([...cart, { ...product, quantity: 1 }]);
    }
  }

  function handleIncrease(productId) {
    setCart(cart.map(item => {
      if (item.id === productId) {
        return { ...item, quantity: item.quantity + 1 };
      } else {
        return item;
      }
    }));
  }

  function handleDecrease(productId) {
    const nextCart = cart
      .map(item => {
        if (item.id === productId) {
          return { ...item, quantity: item.quantity - 1 };
        } else {
          return item;
        }
      })
      .filter(item => item.quantity > 0);
    setCart(nextCart);
  }

  function handleRemove(productId) {
    setCart(cart.filter(item => item.id !== productId));
  }

  return (
    <>
      <h1>Shop</h1>
      <ProductList onAdd={handleAdd} />
      <h2>Cart</h2>
      <Cart
        items={cart}
        onIncrease={handleIncrease}
        onDecrease={handleDecrease}
        onRemove={handleRemove}
      />
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ShoppingCart from './main.jsx';

const products = () => screen.getByRole('list', {name: 'Products'});
const productRow = (name) => within(products()).getAllByRole('listitem').find((li) => li.textContent.startsWith(name + ' '));
const add = (user, name, label = 'Add to cart') => user.click(within(productRow(name)).getByRole('button', {name: label}));
// Each cart line's text without its buttons, e.g. "Bread × 2".
const cartLines = () => (screen.queryByRole('list', {name: 'Cart'})
  ? within(screen.getByRole('list', {name: 'Cart'})).getAllByRole('listitem').map((li) =>
      [...li.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent).join('').trim())
  : []);
const cartRow = (name) => within(screen.getByRole('list', {name: 'Cart'})).getAllByRole('listitem').find((li) => li.textContent.startsWith(name + ' '));
const press = (user, name, label) => user.click(within(cartRow(name)).getByRole('button', {name: label}));

const total = () => screen.queryByText(/^Total:/)?.textContent ?? null;

test('Each cart line shows its price times its quantity', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Bread');
  await add(user, 'Apple');
  assert.deepEqual(cartLines(), ['Bread × 2 €4.98', 'Apple × 1 €0.50'], 'the cart lines are ' + JSON.stringify(cartLines()));
});

test('The total adds up every line', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  for (let i = 0; i < 3; i++) await add(user, 'Apple');
  await add(user, 'Cheese');
  assert.equal(total(), 'Total: €5.75', 'with Apple × 3 and Cheese × 1 the page shows ' + JSON.stringify(total()));
});

test('Add 2 adds two at once', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Cheese', 'Add 2');
  assert.deepEqual(cartLines(), ['Cheese × 2 €8.50'], 'after one click on Add 2 the cart lines are ' + JSON.stringify(cartLines()));
  await add(user, 'Cheese', 'Add 2');
  assert.deepEqual(cartLines(), ['Cheese × 4 €17.00'], 'after two clicks on Add 2 the cart lines are ' + JSON.stringify(cartLines()));
});

test('An empty cart shows no total', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  assert.equal(total(), null, 'the empty cart shows ' + JSON.stringify(total()));
  await add(user, 'Apple');
  await press(user, 'Apple', 'Remove');
  assert.equal(total(), null, 'after removing the last item the page shows ' + JSON.stringify(total()));
  assert.ok(screen.queryByText('Your cart is empty.'), 'the empty-cart message is not shown');
});

data.js

export const products = [
  { id: 0, name: 'Apple', priceCents: 50 },
  { id: 1, name: 'Bread', priceCents: 249 },
  { id: 2, name: 'Cheese', priceCents: 425 }
];

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

reduce without a start value on an empty cart

import { useState } from 'react';

export default function Cart() {
  const [cart, setCart] = useState([]);

  const totalCents = cart.reduce(
    (sum, item) => sum + item.priceCents * item.quantity
  );

  return (
    <>
      <p>{cart.length} items</p>
      <p>Total: €{(totalCents / 100).toFixed(2)}</p>
    </>
  );
}

What React prints

TypeError: Reduce of empty array with no initial value

Why, and the fix

Without a second argument, reduce starts from the first item, and an empty array has none, so the first render throws (Chrome and Node wording; Firefox and Safari word it differently). It would also add up item objects, not numbers. Give reduce a start value of 0: cart.reduce((sum, item) => sum + item.priceCents * item.quantity, 0).

Prices as strings

const products = [
  { id: 0, name: 'Bread', price: '2.49' },
  { id: 1, name: 'Apple', price: '0.50' }
];

export default function ProductList() {
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          {product.name} €{product.price.toFixed(2)}
        </li>
      ))}
    </ul>
  );
}

What React prints

TypeError: product.price.toFixed is not a function

Why, and the fix

'2.49' is a string, and strings have no toFixed method, so the render throws (Chrome and Node wording; Firefox and Safari word it differently). Strings would also join instead of adding: '2.49' + '0.50' is '2.490.50'. Keep prices as whole numbers of cents, priceCents: 249, and format only for display: '€' + (cents / 100).toFixed(2).

Looking up a product by the wrong field

import { useState } from 'react';

const products = [
  { id: 0, name: 'Apple', priceCents: 50 },
  { id: 1, name: 'Bread', priceCents: 249 }
];

export default function Cart() {
  const [cart, setCart] = useState([{ productId: 1, quantity: 2 }]);

  return (
    <ul>
      {cart.map(item => {
        const product = products.find(p => p.id === item.id);
        const lineCents = product.priceCents * item.quantity;
        return <li key={item.productId}>{product.name}: {lineCents} cents</li>;
      })}
    </ul>
  );
}

What React prints

TypeError: Cannot read properties of undefined (reading 'priceCents')

Why, and the fix

The cart items have productId, not id, so item.id is undefined, find finds no product and returns undefined, and the render throws when it reads priceCents (Chrome and Node wording; Firefox and Safari word it differently). Compare the right fields: products.find(p => p.id === item.productId).

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

The cart is an array of new objects

The products live in data.js, each with an id, a name and a price in whole cents. The cart is state: an array of items, each a copy of a product with a quantity, { ...product, quantity: 1 }. The copy leaves the product in data.js unchanged. Adding works in two ways. If the cart has no item with that id yet, cart.some(…) is false and you append: setCart([...cart, { ...product, quantity: 1 }]). Otherwise you map the cart and return a new object with quantity + 1 for that id, and every other item unchanged. Adding a second copy instead would show the product twice, with two equal keys.

Quantities: map, then filter

+ maps the cart to a new array in which only the clicked item is a new object with quantity + 1. - does the same with quantity - 1, then filters out every item whose quantity is no longer above 0, so the cart never shows 0: at quantity 1, - removes the item. Remove filters by id: cart.filter(item => item.id !== productId). Each handler gets the product's id from an arrow function in the JSX, onClick={() => onDecrease(item.id)}. When the last item goes, the cart array is empty, and the Cart component shows Your cart is empty. instead of the list.

Totals in cents, computed while rendering

Prices are whole cents, 249 for €2.49, because JavaScript numbers add decimals inexactly: 0.1 + 0.2 is 0.30000000000000004. Each line costs priceCents * quantity; the total is cart.reduce((sum, item) => sum + item.priceCents * item.quantity, 0), with 0 as the start value. Format only for display: '€' + (cents / 100).toFixed(2) gives €0.50, not €0.5. The total is not state: it can be calculated from the cart while rendering, and a second state could drift out of sync. An Add 2 button that calls onAdd twice in one click needs an updater, setCart(c => …), so the second call builds on the first.

Sources

Last reviewed October 5, 2026