Redux Toolkit: Building a Shopping Cart in React
Redux Toolkit: Building a Shopping Cart
~18 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
Redux is the oldest and still most widely used state management library in the React ecosystem – and correspondingly common in job postings. Redux Toolkit ("RTK" for short) is the official, now-recommended way to write Redux – it eliminates almost all the boilerplate that made "classic" Redux notorious.
Achtung: In a real project you would normally NOT use Zustand AND Redux Toolkit at the same time – you pick one state management solution. We deliberately build BOTH into this same app (auth stays on Zustand, the new shopping cart uses Redux Toolkit) so you can compare both approaches directly, side by side, in the same project – this is a teaching device, not a production recommendation.
The core idea of Redux (before we add Toolkit's sugar)
Redux has three basic rules: (1) The ENTIRE global state lives in ONE single object, the store. (2) The state is NEVER changed directly (state.items.push(...) is forbidden) – instead, you describe WHAT should happen via a plain object, an action (e.g. {{ type: 'cart/addItem', payload: {{...}} }}). (3) A pure function, the reducer, takes the OLD state and an action and returns a COMPLETELY NEW state, without mutating the old one.
Classic Redux required writing all of that by hand: action type constants, action creator functions, switch statements in the reducer, manual {{...state}} spreading to honor rule (3). Redux Toolkit automates almost all of it.
Installing Redux Toolkit and React-Redux
npm install @reduxjs/toolkit react-reduxTwo separate packages: @reduxjs/toolkit contains the store/reducer logic (framework-agnostic, technically works even without React), react-redux provides the React hooks (useSelector, useDispatch) that connect the store to components.
Creating src/store/cartSlice.js
createSlice() is the heart of RTK: one call generates both the reducer AND the matching action creator functions – named after the keys in reducers.
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; // looks like mutation, but is safe - see explanation below
} 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 and state.items.push(...) look like forbidden direct mutation – but Redux Toolkit internally uses the Immer library, which RECORDS this "mutating" code and automatically converts it into a real, unmutated new state. This only applies INSIDE createSlice reducers – writing state.items.push(...) anywhere else in the code would still be a real, broken mutation bug.
Creating src/store/index.js: assembling the store
import { configureStore } from '@reduxjs/toolkit';
import cartReducer from './cartSlice';
export const store = configureStore({
reducer: {
cart: cartReducer,
},
});The cart key in the reducer object determines where in the global state tree this slice lives – state.cart.items, not just state.items. A Redux store can combine any number of such slices (configureStore handles combineReducers automatically).
main.jsx: wiring up the Redux provider
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: Unlike Zustand, React-Redux actually DOES need a <Provider> wrapping the tree – Redux internally uses React Context to pass the store down to useSelector/useDispatch. The crucial difference from our original AuthContext problem: React-Redux's internal context value NEVER changes (it's just a reference to the store itself), useSelector does the actual, targeted subscription logic itself, outside the context mechanism.
ProductCard.jsx: adding an "Add to Cart" button
Important for clean design: ProductCard does NOT import useDispatch directly – just like the "Dumb Components" pattern from "React for Beginners" (chapter 13), it stays a pure, reusable display component that receives an onAddToCart callback from outside, without ever knowing Redux exists:
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(); // just like the favorite button: don't let the click bubble to onSelect
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}>
Add to Cart
</button>
</div>
);
}
export default ProductCard;ProductListPage.jsx: wiring up dispatch
Here's the current, complete state of ProductListPage.jsx (with pagination from "React for Beginners" chapter 24), extended with useDispatch and the onAddToCart prop for ProductCard:
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>Loading products...</p>;
}
if (error) {
return (
<div>
<p>Could not load products: {error}</p>
<button onClick={loadProducts}>Try again</button>
</div>
);
}
return (
<div>
<input
ref={searchInputRef}
type="text"
placeholder="Search products..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{mostExpensiveProduct && (
<p>Most expensive product on this page: {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)}>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
Next
</button>
</div>
</div>
);
}
export default ProductListPage;dispatch(addItem({{...}})) is the complete pattern: addItem(...) (imported from cartSlice.js) creates the action object ({{ type: 'cart/addItem', payload: {{...}} }}), dispatch(...) sends it to the store, the reducer processes it. onAddToCart is passed as an inline arrow function, instead of calling useDispatch inside ProductCard itself – that's exactly what separates "knows about Redux" (ProductListPage) from "knows nothing about Redux" (ProductCard).
Creating CartWidget.jsx: the cart display in the header
useSelector is React-Redux's equivalent of Zustand's selector functions – same principle, different library: the component only re-renders when the SELECTED result changes, not on every arbitrary state change in the store.
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">
???? Cart ({itemCount})
</Link>
);
}
export default CartWidget;The selector (state) => state.cart.items.reduce(...) computes a DERIVED number (total item count), not a raw state value. useSelector still only compares the RESULT – if, say, only an item's name changed (which never happens for us, but for illustration) without itemCount changing, CartWidget does NOT re-render.
Creating CartPage.jsx: the full cart page
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>Your cart is empty.</p>;
}
return (
<div>
<h2>Your Cart</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))}>Remove</button>
</li>
))}
</ul>
<p><strong>Total: ${total.toFixed(2)}</strong></p>
<button onClick={() => dispatch(clearCart())}>Clear Cart</button>
</div>
);
}
export default CartPage;App.jsx: wiring up the route and the widget
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 is deliberately placed NEXT TO the auth area in the header, not nested inside/around it – both are independent, parallel consumers of two different stores (Zustand for auth, Redux for cart), neither knows anything about the other.
Bonus: Redux DevTools
Install the "Redux DevTools" browser extension (Chrome/Firefox) – configureStore AUTOMATICALLY enables the connection to it in development mode, with zero extra configuration (classic Redux required manual setup for this). Open the DevTools, click "Add to Cart" – you'll see every single action with its exact payload AND can even rewind the state history over time ("time-travel debugging").
Tipp: Zustand vs. Redux Toolkit in one sentence: Zustand is minimal and quick to set up, ideal for small-to-medium stores; Redux Toolkit brings more structure, better DevTools, and is often the more consistent choice in large teams/codebases, precisely because it's so widespread and asked about in job postings. Both solve the SAME underlying problem (selective subscriptions instead of context-wide re-renders) with a different API philosophy.