Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Redux Toolkit: Warenkorb in React bauen

Redux Toolkit: Einen Warenkorb bauen

~18 Min. Lesezeit Zuletzt aktualisiert am 8. August 2026

Redux ist die älteste und immer noch am weitesten verbreitete State-Management-Bibliothek im React-Ökosystem – und in Stellenanzeigen entsprechend häufig gefordert. Redux Toolkit (kurz "RTK") ist die offizielle, heute empfohlene Art, Redux zu schreiben – sie eliminiert fast das gesamte Boilerplate, das "klassisches" Redux berüchtigt gemacht hat.

Achtung: In einem echten Projekt würde man normalerweise NICHT Zustand UND Redux Toolkit gleichzeitig einsetzen – man entscheidet sich für eine State-Management-Lösung. Wir bauen hier bewusst BEIDE in dieselbe App ein (Auth bleibt bei Zustand, der neue Warenkorb nutzt Redux Toolkit), damit Sie beide Ansätze direkt nebeneinander im selben Projekt vergleichen können – das ist ein didaktischer Kunstgriff, keine Produktionsempfehlung.

Die Grundidee von Redux (bevor wir Toolkit-Zucker draufpacken)

Redux hat drei Grundregeln: (1) Der GESAMTE globale State lebt in EINEM einzigen Objekt, dem Store. (2) Der State wird NIE direkt verändert (state.items.push(...) ist verboten) – stattdessen beschreibt man WAS passieren soll über ein einfaches Objekt, eine Action (z. B. {{ type: 'cart/addItem', payload: {{...}} }}). (3) Eine reine Funktion, der Reducer, nimmt den ALTEN State und eine Action entgegen und gibt einen KOMPLETT NEUEN State zurück, ohne den alten zu verändern.

Klassisches Redux erforderte, das alles von Hand zu schreiben: Action-Type-Konstanten, Action-Creator-Funktionen, switch-Statements im Reducer, manuelles {{...state}}-Spreading, um Regel (3) einzuhalten. Redux Toolkit automatisiert fast alles davon.

Redux Toolkit und React-Redux installieren

npm install @reduxjs/toolkit react-redux

Zwei getrennte Pakete: @reduxjs/toolkit enthält die Store-/Reducer-Logik (framework-unabhängig, funktioniert theoretisch auch ohne React), react-redux stellt die React-Hooks (useSelector, useDispatch) bereit, die den Store an Komponenten anbinden.

src/store/cartSlice.js anlegen

createSlice() ist das Herzstück von RTK: ein Aufruf erzeugt gleichzeitig den Reducer UND die passenden Action-Creator-Funktionen – benannt nach den Schlüsseln in reducers.

src/store/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    addItem(state, action) {
      const { sku, name, price } = action.payload;
      const existing = state.items.find((item) => item.sku === sku);
      if (existing) {
        existing.quantity += 1; // sieht aus wie Mutation, ist aber sicher - siehe Erklärung unten
      } else {
        state.items.push({ sku, name, price, quantity: 1 });
      }
    },
    removeItem(state, action) {
      state.items = state.items.filter((item) => item.sku !== action.payload);
    },
    updateQuantity(state, action) {
      const { sku, quantity } = action.payload;
      const item = state.items.find((item) => item.sku === sku);
      if (item) {
        item.quantity = Math.max(1, quantity);
      }
    },
    clearCart(state) {
      state.items = [];
    },
  },
});

export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

Achtung: existing.quantity += 1 und state.items.push(...) sehen aus wie verbotene direkte Mutation – Redux Toolkit nutzt intern aber die Bibliothek Immer, die diesen "mutierenden" Code AUFZEICHNET und automatisch in einen echten, unveränderten neuen State umwandelt. Das gilt NUR innerhalb von createSlice-Reducern – irgendwo sonst im Code state.items.push(...) zu schreiben, wäre weiterhin ein echter, kaputter Mutations-Bug.

src/store/index.js anlegen: den Store zusammenbauen

src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';

export const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
});

Der Schlüssel cart im reducer-Objekt bestimmt, unter welchem Namen dieser Slice im globalen State-Baum liegt – state.cart.items, nicht nur state.items. Ein Redux-Store kann beliebig viele solcher Slices kombinieren (configureStore übernimmt combineReducers automatisch).

main.jsx: den Redux-Provider einbinden

src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import App from './App.jsx';
import { store } from './store';
import './index.css';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Provider>
  </StrictMode>
);

Achtung: Anders als bei Zustand BRAUCHT React-Redux tatsächlich einen <Provider>, der den Baum umschließt – Redux nutzt intern React Context, um den Store an useSelector/useDispatch weiterzureichen. Der entscheidende Unterschied zu unserem ursprünglichen AuthContext-Problem: React-Redux's interner Context-Wert ändert sich NIE (es ist nur eine Referenz auf den Store selbst), useSelector erledigt die eigentliche, gezielte Abo-Logik selbst, außerhalb des Context-Mechanismus.

ProductCard.jsx: einen "In den Warenkorb"-Button hinzufügen

Wichtig für sauberes Design: ProductCard importiert NICHT direkt useDispatch – wie schon beim "Dumb Components"-Muster aus "React für Einsteiger" (Kapitel 13) bleibt es eine reine, wiederverwendbare Anzeige-Komponente, die einen onAddToCart-Callback von außen bekommt, ohne zu wissen, DASS es Redux überhaupt gibt:

src/components/ProductCard.jsx
import { useState } from 'react';

function ProductCard({ name, price, imageUrl, onSelect, onAddToCart }) {
  const [isFavorite, setIsFavorite] = useState(false);

  function handleFavoriteClick(event) {
    event.stopPropagation();
    setIsFavorite(!isFavorite);
  }

  function handleAddToCartClick(event) {
    event.stopPropagation(); // genau wie beim Favoriten-Button: Klick nicht an onSelect durchreichen
    onAddToCart();
  }

  return (
    <div className="product-card" onClick={onSelect}>
      <img src={imageUrl} alt={name} className="product-card__image" />
      <div className="product-card__info">
        <h3 className="product-card__name">{name}</h3>
        <p className="product-card__price">${price.toFixed(2)}</p>
      </div>
      <button className="product-card__favorite" onClick={handleFavoriteClick}>
        {isFavorite ? '♥' : '♡'}
      </button>
      <button className="product-card__add-to-cart" onClick={handleAddToCartClick}>
        In den Warenkorb
      </button>
    </div>
  );
}

export default ProductCard;

ProductListPage.jsx: dispatch verdrahten

Hier der aktuelle, vollständige Stand von ProductListPage.jsx (mit Pagination aus "React für Einsteiger" Kapitel 24), erweitert um useDispatch und die sku/onAddToCart-Props für ProductCard:

src/pages/ProductListPage.jsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import ProductCard from '../components/ProductCard';
import { fetchProducts } from '../api/magentoApi';
import { addItem } from '../store/cartSlice';

const PAGE_SIZE = 6;

function ProductListPage() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [query, setQuery] = useState('');
  const [page, setPage] = useState(1);
  const [totalCount, setTotalCount] = useState(0);
  const searchInputRef = useRef(null);
  const navigate = useNavigate();
  const dispatch = useDispatch();

  async function loadProducts() {
    setLoading(true);
    setError(null);
    try {
      const { items, totalCount: count } = await fetchProducts(page, PAGE_SIZE);
      setProducts(items);
      setTotalCount(count);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    loadProducts();
  }, [page]);

  useEffect(() => {
    if (!loading && !error) {
      searchInputRef.current?.focus();
    }
  }, [loading, error]);

  const totalPages = Math.ceil(totalCount / PAGE_SIZE);

  const filtered = products.filter((product) =>
    product.name.toLowerCase().includes(query.toLowerCase())
  );

  const mostExpensiveProduct = useMemo(() => {
    return filtered.reduce(
      (max, p) => (p.price > (max?.price ?? 0) ? p : max),
      null
    );
  }, [filtered]);

  if (loading) {
    return <p>Lade Produkte...</p>;
  }

  if (error) {
    return (
      <div>
        <p>Produkte konnten nicht geladen werden: {error}</p>
        <button onClick={loadProducts}>Erneut versuchen</button>
      </div>
    );
  }

  return (
    <div>
      <input
        ref={searchInputRef}
        type="text"
        placeholder="Produkte suchen..."
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      {mostExpensiveProduct && (
        <p>Teuerstes Produkt auf dieser Seite: {mostExpensiveProduct.name} (${mostExpensiveProduct.price.toFixed(2)})</p>
      )}
      <div className="product-grid">
        {filtered.map((product) => (
          <ProductCard
            key={product.sku}
            name={product.name}
            price={product.price}
            imageUrl={product.imageUrl}
            onSelect={() => navigate(`/products/${product.sku}`)}
            onAddToCart={() =>
              dispatch(addItem({ sku: product.sku, name: product.name, price: product.price }))
            }
          />
        ))}
      </div>
      <div className="pagination">
        <button disabled={page === 1} onClick={() => setPage(page - 1)}>
          Zurück
        </button>
        <span>Seite {page} von {totalPages}</span>
        <button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
          Weiter
        </button>
      </div>
    </div>
  );
}

export default ProductListPage;

dispatch(addItem({{...}})) ist das komplette Muster: addItem(...) (importiert aus cartSlice.js) erzeugt das Action-Objekt ({{ type: 'cart/addItem', payload: {{...}} }}), dispatch(...) schickt es an den Store, der Reducer verarbeitet es. onAddToCart als Inline-Arrow-Function übergeben, statt useDispatch in ProductCard selbst aufzurufen – genau das trennt "weiß von Redux" (ProductListPage) von "weiß nichts von Redux" (ProductCard).

CartWidget.jsx anlegen: Warenkorb-Anzeige im Header

useSelector ist React-Redux's Äquivalent zu Zustands Selektor-Funktionen – gleiches Prinzip, andere Bibliothek: die Komponente rendert nur neu, wenn sich das SELEKTIERTE Ergebnis ändert, nicht bei jeder beliebigen State-Änderung im Store.

src/components/CartWidget.jsx
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';

function CartWidget() {
  const itemCount = useSelector((state) =>
    state.cart.items.reduce((total, item) => total + item.quantity, 0)
  );

  return (
    <Link to="/cart" className="cart-widget">
      ???? Warenkorb ({itemCount})
    </Link>
  );
}

export default CartWidget;

Der Selektor (state) => state.cart.items.reduce(...) berechnet eine ABGELEITETE Zahl (Gesamtanzahl aller Artikel), keinen rohen State-Wert. useSelector vergleicht trotzdem nur das ERGEBNIS – ändert sich z. B. nur der name eines Artikels (was bei uns nie passiert, aber zur Veranschaulichung), ohne dass sich itemCount ändert, rendert CartWidget NICHT neu.

CartPage.jsx anlegen: die vollständige Warenkorb-Seite

src/pages/CartPage.jsx
import { useSelector, useDispatch } from 'react-redux';
import { removeItem, updateQuantity, clearCart } from '../store/cartSlice';

function CartPage() {
  const items = useSelector((state) => state.cart.items);
  const dispatch = useDispatch();

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

  if (items.length === 0) {
    return <p>Ihr Warenkorb ist leer.</p>;
  }

  return (
    <div>
      <h2>Ihr Warenkorb</h2>
      <ul>
        {items.map((item) => (
          <li key={item.sku}>
            {item.name} – ${item.price.toFixed(2)} ×{' '}
            <input
              type="number"
              min="1"
              value={item.quantity}
              onChange={(event) =>
                dispatch(updateQuantity({ sku: item.sku, quantity: Number(event.target.value) }))
              }
            />
            <button onClick={() => dispatch(removeItem(item.sku))}>Entfernen</button>
          </li>
        ))}
      </ul>
      <p><strong>Gesamt: ${total.toFixed(2)}</strong></p>
      <button onClick={() => dispatch(clearCart())}>Warenkorb leeren</button>
    </div>
  );
}

export default CartPage;

App.jsx: Route und Widget verdrahten

src/App.jsx
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
import { useDocumentTitle } from './hooks/useDocumentTitle';
import RenderCounter from './components/RenderCounter';
import CartWidget from './components/CartWidget';
import ProductListPage from './pages/ProductListPage';
import ProductDetailPage from './pages/ProductDetailPage';
import ProtectedRoute from './components/ProtectedRoute';

const ReviewsPage = lazy(() => import('./pages/ReviewsPage'));
const LoginPage = lazy(() => import('./pages/LoginPage'));
const AccountPage = lazy(() => import('./pages/AccountPage'));
const CartPage = lazy(() => import('./pages/CartPage'));

function App() {
  const user = useAuthStore((state) => state.user);
  const login = useAuthStore((state) => state.login);
  const logout = useAuthStore((state) => state.logout);
  useDocumentTitle('Product Catalog');

  return (
    <div className="app">
      <header className="app-header">
        <h1>Product Catalog</h1>
        <RenderCounter label="App-Header" />
        <CartWidget />
        {user ? (
          <p>Logged in as {user.username} <button onClick={logout}>Log out</button></p>
        ) : (
          <button onClick={() => login('Jane Doe')}>Log in</button>
        )}
      </header>

      <Suspense fallback={<p>Loading page...</p>}>
        <Routes>
          <Route path="/" element={<ProductListPage />} />
          <Route path="/products/:sku" element={<ProductDetailPage />}>
            <Route path="reviews" element={<ReviewsPage />} />
          </Route>
          <Route path="/cart" element={<CartPage />} />
          <Route path="/login" element={<LoginPage />} />
          <Route
            path="/account"
            element={
              <ProtectedRoute>
                <AccountPage />
              </ProtectedRoute>
            }
          />
        </Routes>
      </Suspense>
    </div>
  );
}

export default App;

CartWidget ist bewusst NEBEN dem Auth-Bereich im Header platziert, nicht darunter/darüber verschachtelt – beide sind unabhängige, parallel existierende Verbraucher zweier unterschiedlicher Stores (Zustand für Auth, Redux für Cart), keiner weiß etwas vom anderen.

Bonus: Redux DevTools

Installieren Sie die Browser-Erweiterung "Redux DevTools" (Chrome/Firefox) – configureStore aktiviert die Anbindung daran AUTOMATISCH im Entwicklungsmodus, ganz ohne zusätzliche Konfiguration (klassisches Redux erforderte dafür manuelles Setup). Öffnen Sie die DevTools, klicken Sie "In den Warenkorb" – Sie sehen jede einzelne Action mit ihrem exakten Payload UND können den State-Verlauf sogar zeitlich zurückspulen ("Time-Travel Debugging").

Tipp: Zustand vs. Redux Toolkit in einem Satz: Zustand ist minimal und schnell eingerichtet, ideal für kleinere bis mittlere Stores; Redux Toolkit bringt mehr Struktur, bessere DevTools und ist in großen Teams/Codebases oft die konsistentere Wahl, gerade weil es so weit verbreitet und in Stellenausschreibungen gefragt ist. Beide lösen das GLEICHE Grundproblem (selektive Abonnements statt Context-weite Re-Renders) mit unterschiedlicher API-Philosophie.