React Shopping Cart: Add to Cart & Persist State
A shopping cart is the bridge between product discovery and purchase. Building a robust cart in React requires managing items in state, calculating totals, persisting data across page reloads, and syncing with a backend API. This article shows you how to create a cart using Zustand for state management and localStorage for persistence. You'll learn to add and remove items, handle quantity changes, calculate subtotals and taxes, and keep the cart alive when users close their browser and return later. By the end, your cart will behave like real-world stores: persistent, fast, and reliable.
Set Up a Zustand Cart Store
Create src/store/cartStore.js:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCartStore = create(
persist(
(set) => ({
items: [],
addItem: (product, quantity = 1) =>
set((state) => {
const existing = state.items.find((item) => item.id === product.id);
if (existing) {
return {
items: state.items.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
),
};
}
return {
items: [...state.items, { ...product, quantity }],
};
}),
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
})),
updateQuantity: (productId, quantity) =>
set((state) => ({
items: state.items.map((item) =>
item.id === productId ? { ...item, quantity } : item
),
})),
clearCart: () => set({ items: [] }),
getTotal: (state) =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}),
{
name: 'cart-store',
storage: localStorage,
}
)
);
Zustand's persist middleware automatically syncs your state to localStorage. When the user refreshes the page, the cart items are restored from localStorage—no code needed. The addItem function checks if a product is already in the cart and increments quantity, or adds a new item.
Create an "Add to Cart" Button
Modify your ProductCard.jsx to add items to the cart:
import { useCartStore } from '../store/cartStore';
export default function ProductCard({ product }) {
const addItem = useCartStore((state) => state.addItem);
const [showConfirm, setShowConfirm] = useState(false);
const handleAddToCart = (e) => {
e.preventDefault(); // Prevent navigation to detail page
addItem(product, 1);
setShowConfirm(true);
setTimeout(() => setShowConfirm(false), 2000);
};
return (
<Link to={`/products/${product.id}`}>
<div className="bg-white rounded-lg shadow hover:shadow-lg transition p-4">
{/* ... existing product card content ... */}
<button
onClick={handleAddToCart}
className={`w-full py-2 rounded font-semibold transition ${
product.inStock
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
disabled={!product.inStock}
>
{product.inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
{showConfirm && (
<div className="mt-2 text-sm text-green-600 font-semibold">
Added to cart!
</div>
)}
</div>
</Link>
);
}
When the user clicks "Add to Cart," the product is added to the Zustand store and a confirmation message appears for 2 seconds. The e.preventDefault() stops the Link from navigating to the detail page.
Build the Cart Page Component
Create src/pages/CartPage.jsx:
import { Link } from 'react-router-dom';
import { useCartStore } from '../store/cartStore';
export default function CartPage() {
const items = useCartStore((state) => state.items);
const updateQuantity = useCartStore((state) => state.updateQuantity);
const removeItem = useCartStore((state) => state.removeItem);
const getTotal = useCartStore((state) => state.getTotal);
const subtotal = getTotal({ items });
const tax = subtotal * 0.08; // 8% sales tax
const total = subtotal + tax;
if (items.length === 0) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold mb-4">Your Cart is Empty</h1>
<p className="text-gray-600 mb-6">
Start shopping to add items to your cart.
</p>
<Link
to="/"
className="bg-blue-600 text-white px-6 py-2 rounded font-semibold hover:bg-blue-700"
>
Continue Shopping
</Link>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<h1 className="text-3xl font-bold mb-8">Shopping Cart</h1>
<div className="grid grid-cols-3 gap-8">
{/* Cart Items */}
<div className="col-span-2 bg-white rounded-lg shadow p-6">
{items.map((item) => (
<div
key={item.id}
className="flex gap-4 pb-6 border-b last:border-b-0 last:pb-0"
>
<img
src={item.imageUrl}
alt={item.name}
className="w-24 h-24 object-cover rounded"
/>
<div className="flex-1">
<h3 className="font-semibold text-lg">{item.name}</h3>
<p className="text-gray-600 text-sm">{item.description}</p>
<p className="text-lg font-bold text-gray-900 mt-2">
${item.price.toFixed(2)}
</p>
</div>
{/* Quantity Controls */}
<div className="flex flex-col items-center gap-2">
<div className="flex border rounded">
<button
onClick={() =>
updateQuantity(item.id, Math.max(1, item.quantity - 1))
}
className="px-3 py-1 hover:bg-gray-100"
>
−
</button>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) =>
updateQuantity(item.id, parseInt(e.target.value) || 1)
}
className="w-12 text-center border-l border-r text-sm"
/>
<button
onClick={() =>
updateQuantity(item.id, item.quantity + 1)
}
className="px-3 py-1 hover:bg-gray-100"
>
+
</button>
</div>
<button
onClick={() => removeItem(item.id)}
className="text-red-600 hover:text-red-800 text-sm font-semibold"
>
Remove
</button>
</div>
{/* Line Total */}
<div className="text-right">
<p className="text-lg font-bold">
${(item.price * item.quantity).toFixed(2)}
</p>
</div>
</div>
))}
</div>
{/* Order Summary */}
<div className="bg-white rounded-lg shadow p-6 h-fit">
<h2 className="text-xl font-semibold mb-6">Order Summary</h2>
<div className="space-y-4 mb-6">
<div className="flex justify-between">
<span className="text-gray-600">Subtotal</span>
<span>${subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Tax (8%)</span>
<span>${tax.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Shipping</span>
<span>FREE</span>
</div>
<div className="border-t pt-4 flex justify-between text-lg font-bold">
<span>Total</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
<Link
to="/checkout"
className="w-full bg-blue-600 text-white py-3 rounded font-semibold hover:bg-blue-700 block text-center"
>
Proceed to Checkout
</Link>
<Link
to="/"
className="w-full mt-3 border border-gray-300 py-3 rounded font-semibold hover:bg-gray-50 block text-center text-gray-700"
>
Continue Shopping
</Link>
</div>
</div>
</div>
</div>
);
}
This page displays all cart items with quantity controls, removes individual items, and shows a summary with subtotal, tax, and shipping. The localStorage persistence means users can close the tab and return later—their cart is still there.
Handle Edge Cases
When a product goes out of stock after being added to cart, show a warning:
const outOfStockItems = items.filter((item) => !item.inStock);
if (outOfStockItems.length > 0) {
return (
<div className="bg-yellow-100 border border-yellow-400 p-4 rounded mb-4">
<p className="text-yellow-800">
Some items in your cart are no longer in stock. They have been removed.
</p>
</div>
);
}
Your backend should validate that products are still in stock before accepting an order at checkout—never trust the frontend.
Key Takeaways
- Use Zustand with the
persistmiddleware to keep cart state in localStorage and restore it on page reload. - Additem logic should check if a product already exists and increment quantity, not create duplicates.
- Calculate totals (subtotal, tax, shipping) in the order summary component, derived from cart items.
- Provide quantity controls with increment/decrement buttons and an input field for direct editing.
- Always validate cart contents on the backend before accepting payment—frontend state can be manipulated.
Frequently Asked Questions
What if a user has 1000 items in their cart?
While rare, this is possible. Your cart logic should handle large item lists efficiently. Zustand and localStorage are fast enough for thousands of items, but you may want to paginate the cart display for UX.
Should I sync the cart to my backend immediately?
For small stores, localStorage is fine. For larger stores or logged-in users, sync the cart to the backend every 30 seconds and on logout. This prevents cart loss if localStorage is cleared.
How do I handle cart items that have price changes?
When the user proceeds to checkout, fetch the latest product prices from the API. If a price has changed significantly, alert the user before charging them the old price.
Can I use the browser's session storage instead of localStorage?
Session storage clears when the browser tab closes. Use localStorage for carts so users don't lose items if they accidentally close the tab.