Zum Inhalt springen
aviral gupta

// B3.6 · ca. 45 Min. · Einstieg

Build: ein Warenkorb

In diesem Build bauen Sie einen Warenkorb: Produkte hinzufügen, Mengen ändern, Einträge entfernen und eine Summe in Euro zeigen, alles mit unveränderlichen Updates.

Lektion 6 von 6 in B3 Interaktivität hinzufügen

Ende des Moduls

Danach können Sie

  • Ein Produkt als neuen Eintrag in den Warenkorb legen oder seine Menge erhöhen
  • Mengen mit map ändern, Einträge mit filter entfernen und einen Eintrag bei 0 streichen
  • Summen aus Cent beim Rendern berechnen und formatieren, und mit einem Updater zweimal hinzufügen
  1. Aufwärmen · Aufgabe 1 von 7

    Zum Aufwärmen aus Lektion B3.5. Coffee hat die Anzahl 1, Rice 2. Der Handler von - ruft map auf, dann filter. Sie klicken bei Coffee auf -. Was zeigt die Liste?

    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. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie voraus, bevor Sie weiterlesen. handleAdd hängt immer eine Kopie des Produkts mit Menge 1 an. Sie klicken zweimal auf Bread. Wie viele <li> zeigt der Warenkorb?

    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. Üben · Aufgabe 3 von 7

    Ergänzen Sie die Zahlenmethode, sodass formatPrice(50) €0.50 ergibt, formatPrice(249) €2.49 und formatPrice(1700) €17.00.

    function formatPrice(cents) {
      return '€' + (cents / 100).____(2);
    }
    (cents / 100).(2)
  4. Üben · Aufgabe 4 von 7

    Ordnen Sie jeder Aktion im Warenkorb das Update zu, das sie braucht.

  5. Üben · Aufgabe 5 von 7

    Was gibt das aus?

    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. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. handleAdd führt Einträge korrekt zusammen, und ein Button Add 2 breads ruft es in einem Klick zweimal auf. Sie klicken auf Bread, dann auf Add 2 breads. Was zeigt der Warenkorb?

    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. Anwenden · Aufgabe 7 von 7

    Mini-Aufgabe: eine Überschrift für den Warenkorb. Schreiben Sie in main.jsx eine Komponente CartSummary mit einem State cart aus zwei Einträgen: Apple mit Menge 3 und Bread mit Menge 1. Zeigen Sie die Zahl der Artikel, Mengen mitgezählt, in einem <h2> wie Cart (4 items), mit item bei genau 1. Fügen Sie einen Button Clear cart hinzu, der den Warenkorb leert. Halten Sie die Zahl nicht im State. Führen Sie es aus und klicken Sie auf Clear cart.

    Prüfen Sie Ihr Ergebnis anhand dieser Liste

Selbst programmieren

Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.

Ausgearbeitetes Beispiel

Eine Vorratsliste: + und - mit map und filter

Vor dem Warenkorb der Kern seiner Mengen-Buttons, wie in den Warenkorb-Aufgaben auf react.dev, im eigenen Code dieser Lektion. Pantry hält ein Array von Einträgen mit einer Anzahl. + bildet mit map ein neues Array, in dem nur der angeklickte Eintrag ein neues Objekt ist. - bildet ebenso ab und filtert dann Einträge heraus, deren Anzahl 0 erreicht hat, sodass die Liste nie 0 zeigt. Ein Lauf hängt die Komponente nur ein, also klicken Timer unten in main.jsx bei Rice auf +, dann bei Coffee auf -, und geben aus, wie viele Einträge übrig sind.

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);

Ausführen mit

npm run dev

Ausgabe

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>
  • + bei Rice hat ein neues Rice-Objekt mit Anzahl 3 erzeugt; Coffee und Tea blieben dieselben Objekte.
  • - bei Coffee hat seine Anzahl auf 0 gesetzt, und filter hat es entfernt; zwei Einträge bleiben.
  • initialItems wurde nie geändert: Jedes Update hat neue Arrays und neue Objekte erzeugt.
  • Die Übungen machen daraus einen Warenkorb: Produkte aus data.js, Mengen, ein Button Remove und eine Summe.
Ändern und ausführen

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser React und den Compiler herunter, der JSX in JavaScript übersetzt (bis zu 2 MB), und speichert beides im Cache. Ihre Komponenten laufen in Ihrem Browser und bleiben auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Übungen

Übung 1 von 3

Produkte in den Warenkorb legen

Der Shop listet drei Produkte aus data.js, jedes mit einem Button Add to cart, und einen leeren Warenkorb. handleAdd schiebt in das State-Array, also erscheint nichts. Schreiben Sie es ohne Mutation neu. Hat der Warenkorb keinen Eintrag mit der id des Produkts, hängen Sie { ...product, quantity: 1 } an. Hat er einen, erhöhen Sie dessen Menge mit map um 1, sodass zweimal hinzugefügtes Bread als Bread × 2 erscheint. Lassen Sie data.js unverändert.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser React und den Compiler herunter, der JSX in JavaScript übersetzt (bis zu 2 MB), und speichert beides im Cache. Ihre Komponenten laufen in Ihrem Browser und bleiben auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Hinweise
  1. Hinweis 1

    cart.some(item => item.id === product.id) sagt Ihnen, ob das Produkt schon im Warenkorb liegt.

  2. Hinweis 2

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

  3. Hinweis 3

    Produkt schon da: setCart(cart.map(item => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item)).

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

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} />
    </>
  );
}
Auf dem eigenen Computer ausführen

Installieren Sie React 19.3 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

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('Der Warenkorb ist anfangs leer', () => {
  render(<ShoppingCart />);
  assert.ok(screen.queryByText('Your cart is empty.'), 'die Seite zeigt keine Meldung "Your cart is empty."');
});

test('Add to cart legt das Produkt einmal in den Warenkorb', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  assert.deepEqual(cartLines(), ['Bread × 1'], 'nach dem Hinzufügen von Bread lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
  assert.equal(screen.queryByText('Your cart is empty.'), null, 'die Meldung für den leeren Warenkorb wird noch angezeigt');
});

test('Dasselbe Produkt noch einmal hinzuzufügen erhöht seine Menge', 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'], 'nach Bread, Apple, Bread lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
});

test('Die Produkte in data.js bleiben unverändert', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Cheese');
  await add(user, 'Cheese');
  assert.equal('quantity' in data[2], false, 'nach dem Hinzufügen ist products[2] in data.js ' + 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 }
];

Legen Sie main.jsx in einem Vite-React-Projekt (npm create vite@latest, Vorlage react) als App.jsx in src/ ab und starten Sie es mit npm run dev. React rendert es dort wie hier in <StrictMode>.

Programm ausführen:

npm run dev

Für die Prüfungen gibt es noch keinen Befehl auf Ihrem Computer. Sie stehen in main.test.jsx, geschrieben mit node:test und React Testing Library.

Übung 2 von 3

Mengen ändern und Einträge entfernen

Hinzufügen funktioniert jetzt, und jede Zeile im Warenkorb hat die Buttons +, - und Remove. Schreiben Sie die drei Handler ohne Mutation. + erhöht die Menge dieses Eintrags um 1. - senkt sie um 1 und entfernt den Eintrag, wenn seine Menge 0 erreicht, sodass der Warenkorb nie 0 zeigt. Remove nimmt den Eintrag bei jeder Menge heraus. Geht der letzte Eintrag, zeigt der Warenkorb wieder Your cart is empty.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser React und den Compiler herunter, der JSX in JavaScript übersetzt (bis zu 2 MB), und speichert beides im Cache. Ihre Komponenten laufen in Ihrem Browser und bleiben auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Hinweise
  1. Hinweis 1

    + ist dasselbe map wie beim Hinzufügen eines Produkts, das schon im Warenkorb liegt, nur nach id.

  2. Hinweis 2

    -: map auf quantity - 1 für diese id, dann .filter(item => item.quantity > 0), dann setCart.

  3. Hinweis 3

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

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

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}
      />
    </>
  );
}
Auf dem eigenen Computer ausführen

Installieren Sie React 19.3 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

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('+ erhöht nur diese Menge', 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'], 'nach + bei Bread lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
});

test('- senkt die Menge', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Bread');
  await press(user, 'Bread', '-');
  assert.deepEqual(cartLines(), ['Bread × 1'], 'nach - bei Bread × 2 lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
});

test('- bei Menge 1 entfernt den Eintrag', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Bread');
  await add(user, 'Apple');
  await press(user, 'Apple', '-');
  assert.deepEqual(cartLines(), ['Bread × 1'], 'nach - bei Apple × 1 lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
});

test('Remove nimmt den Eintrag bei jeder Menge heraus', 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(), [], 'nach Remove lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
  assert.ok(screen.queryByText('Your cart is empty.'), 'die Meldung für den leeren Warenkorb ist nicht zurück');
});

data.js

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

Legen Sie main.jsx in einem Vite-React-Projekt (npm create vite@latest, Vorlage react) als App.jsx in src/ ab und starten Sie es mit npm run dev. React rendert es dort wie hier in <StrictMode>.

Programm ausführen:

npm run dev

Für die Prüfungen gibt es noch keinen Befehl auf Ihrem Computer. Sie stehen in main.test.jsx, geschrieben mit node:test und React Testing Library.

Übung 3 von 3

Preise, die Summe und Add 2

Stellen Sie den Warenkorb fertig. Jede Zeile zeigt nach der Menge ihren Preis, priceCents * quantity, formatiert mit formatPrice, z. B. Bread × 2 €4.98. Unter der Liste zeigt ein <p> Total: und die Summe aller Zeilen, z. B. Total: €5.75; berechnen Sie sie beim Rendern, nicht im State, und zeigen Sie sie nur, wenn der Warenkorb Einträge hat. Jedes Produkt hat außerdem einen Button Add 2, der onAdd zweimal aufruft; bisher fügt er nur 1 hinzu. Lassen Sie handleAdd eine Updater-Funktion verwenden, setCart(c => …).

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser React und den Compiler herunter, der JSX in JavaScript übersetzt (bis zu 2 MB), und speichert beides im Cache. Ihre Komponenten laufen in Ihrem Browser und bleiben auf Ihrem Gerät.

Ihre Seite

Ihre Seite läuft in einem abgeschotteten Rahmen. Sie hat keinen Zugriff auf diese Website, und ihr localStorage liegt nur für diesen Lauf im Speicher: Der nächste Lauf beginnt leer.

Führen Sie das Programm aus, um seine Seite hier zu sehen.

Hinweise
  1. Hinweis 1

    In Cart, nach der Prüfung auf leer: const totalCents = items.reduce((sum, item) => sum + item.priceCents * item.quantity, 0);

  2. Hinweis 2

    Jede Zeile: {item.name} × {item.quantity}{' '}{formatPrice(item.priceCents * item.quantity)}{' '}, dann die Buttons; umschließen Sie <ul> und <p>Total: {formatPrice(totalCents)}</p> mit einem Fragment.

  3. Hinweis 3

    Schreiben Sie in handleAdd setCart(c => { … }) und verwenden Sie darin c statt cart; geben Sie in beiden Zweigen das neue Array zurück.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

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}
      />
    </>
  );
}
Auf dem eigenen Computer ausführen

Installieren Sie React 19.3 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

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('Jede Zeile zeigt ihren Preis mal ihre Menge', 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'], 'die Zeilen des Warenkorbs lauten ' + JSON.stringify(cartLines()));
});

test('Die Summe addiert alle Zeilen', 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', 'mit Apple × 3 und Cheese × 1 zeigt die Seite ' + JSON.stringify(total()));
});

test('Add 2 fügt zwei auf einmal hinzu', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  await add(user, 'Cheese', 'Add 2');
  assert.deepEqual(cartLines(), ['Cheese × 2 €8.50'], 'nach einem Klick auf Add 2 lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
  await add(user, 'Cheese', 'Add 2');
  assert.deepEqual(cartLines(), ['Cheese × 4 €17.00'], 'nach zwei Klicks auf Add 2 lauten die Zeilen des Warenkorbs ' + JSON.stringify(cartLines()));
});

test('Ein leerer Warenkorb zeigt keine Summe', async () => {
  const user = userEvent.setup();
  render(<ShoppingCart />);
  assert.equal(total(), null, 'der leere Warenkorb zeigt ' + JSON.stringify(total()));
  await add(user, 'Apple');
  await press(user, 'Apple', 'Remove');
  assert.equal(total(), null, 'nach dem Entfernen des letzten Eintrags zeigt die Seite ' + JSON.stringify(total()));
  assert.ok(screen.queryByText('Your cart is empty.'), 'die Meldung für den leeren Warenkorb wird nicht angezeigt');
});

data.js

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

Legen Sie main.jsx in einem Vite-React-Projekt (npm create vite@latest, Vorlage react) als App.jsx in src/ ab und starten Sie es mit npm run dev. React rendert es dort wie hier in <StrictMode>.

Programm ausführen:

npm run dev

Für die Prüfungen gibt es noch keinen Befehl auf Ihrem Computer. Sie stehen in main.test.jsx, geschrieben mit node:test und React Testing Library.

Häufige Fehler

reduce ohne Startwert bei leerem Warenkorb

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>
    </>
  );
}

Was React ausgibt

TypeError: Reduce of empty array with no initial value

Warum, und die Lösung

Ohne zweites Argument beginnt reduce beim ersten Eintrag, und ein leeres Array hat keinen; das erste Rendern wirft also einen Fehler (Wortlaut von Chrome und Node; Firefox und Safari formulieren ihn anders). Außerdem würde es Eintrags-Objekte addieren statt Zahlen. Geben Sie reduce den Startwert 0: cart.reduce((sum, item) => sum + item.priceCents * item.quantity, 0).

Preise als 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>
  );
}

Was React ausgibt

TypeError: product.price.toFixed is not a function

Warum, und die Lösung

'2.49' ist ein String, und Strings haben keine Methode toFixed; das Rendern wirft also einen Fehler (Wortlaut von Chrome und Node; Firefox und Safari formulieren ihn anders). Strings würden außerdem verkettet statt addiert: '2.49' + '0.50' ergibt '2.490.50'. Halten Sie Preise als ganze Cent, priceCents: 249, und formatieren Sie nur für die Anzeige: '€' + (cents / 100).toFixed(2).

Ein Produkt über das falsche Feld suchen

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>
  );
}

Was React ausgibt

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

Warum, und die Lösung

Die Einträge im Warenkorb haben productId, nicht id; item.id ist also undefined, find findet kein Produkt und gibt undefined zurück, und das Rendern wirft beim Lesen von priceCents (Wortlaut von Chrome und Node; Firefox und Safari formulieren ihn anders). Vergleichen Sie die richtigen Felder: products.find(p => p.id === item.productId).

React im Browser: React 19.3.0 (Entwicklungs-Build) und React Testing Library, MIT, von dieser Website ausgeliefert; JSX übersetzt TypeScript 6.0.3, Apache-2.0. Lizenz und Quellcode

Abschlussquiz

5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.

Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Der Warenkorb ist ein Array neuer Objekte

Die Produkte stehen in data.js, jedes mit id, name und einem Preis in ganzen Cent. Der Warenkorb ist State: ein Array von Einträgen, jeder eine Kopie eines Produkts mit einer Menge, { ...product, quantity: 1 }. Die Kopie lässt das Produkt in data.js unverändert. Hinzufügen geht auf zwei Arten. Hat der Warenkorb noch keinen Eintrag mit dieser id, ist cart.some(…) false, und Sie hängen an: setCart([...cart, { ...product, quantity: 1 }]). Sonst bilden Sie den Warenkorb mit map ab und geben für diese id ein neues Objekt mit quantity + 1 zurück, alle anderen Einträge unverändert. Eine zweite Kopie anzuhängen zeigte das Produkt doppelt, mit zwei gleichen Keys.

Mengen: erst map, dann filter

+ bildet den Warenkorb mit map auf ein neues Array ab, in dem nur der angeklickte Eintrag ein neues Objekt mit quantity + 1 ist. - macht dasselbe mit quantity - 1 und filtert danach jeden Eintrag heraus, dessen Menge nicht mehr über 0 liegt; so zeigt der Warenkorb nie 0, und - bei Menge 1 entfernt den Eintrag. Remove filtert nach id: cart.filter(item => item.id !== productId). Jeder Handler bekommt die id des Produkts aus einer Pfeilfunktion im JSX, onClick={() => onDecrease(item.id)}. Geht der letzte Eintrag, ist das Array leer, und die Komponente Cart zeigt statt der Liste Your cart is empty.

Summen in Cent, beim Rendern berechnet

Preise sind ganze Cent, 249 für 2,49 €, denn JavaScript-Zahlen addieren Dezimalstellen ungenau: 0.1 + 0.2 ergibt 0.30000000000000004. Jede Zeile kostet priceCents * quantity; die Summe ist cart.reduce((sum, item) => sum + item.priceCents * item.quantity, 0), mit 0 als Startwert. Formatiert wird nur für die Anzeige: '€' + (cents / 100).toFixed(2) ergibt €0.50, nicht €0.5. Die Summe ist kein State: Sie lässt sich beim Rendern aus dem Warenkorb berechnen, und ein zweiter State könnte aus dem Takt geraten. Ein Button Add 2, der onAdd in einem Klick zweimal aufruft, braucht einen Updater, setCart(c => …), damit der zweite Aufruf auf dem ersten aufbaut.

Quellen

Zuletzt geprüft am 5. Oktober 2026