Add a cart and order form
Add a working cart to the Brew & Bean site: a CartContext with a reducer, Add buttons, a count in the header, quantity controls, localStorage, and an order form with Indian mobile validation that ends in a confirmation. The order is not sent anywhere yet.
Now the website becomes useful. In this lesson you add a cart that works across all pages and an order form with validation. A customer will be able to add items from the Menu, see the count in the header, change quantities on the Cart page, enter their name, mobile number and pickup time, and see a confirmation. The cart will survive a page refresh.
The order is not sent anywhere yet. We have no server. The confirmation says so honestly. The API lessons later in this track build the server and connect it.
Start from: the project at the end of *Build the home and menu pages* (menu data, MenuCard, Home and Menu pages, committed).
How the cart will work
The cart is needed in three places: each MenuCard has an Add button, the Header shows the number of items, and the Cart page edits the items. Passing the cart down through every component would be messy. React has a feature for this: context. You create a provider near the top of the app, and any component below can read from it with a hook.
We will use a reducer to change the cart. A reducer is a function that takes the current cart and an action (such as add or remove) and returns the new cart. All the rules live in one place, and they are easy to test.
Design decisions (from the spec)
| Decision | Choice | Why |
|---|---|---|
| Cart data | A list of { id, qty } | Names and prices are looked up from the menu, so a price can never be wrong or edited |
| Where it lives | CartContext with useReducer | Shared by Header, MenuCard and Cart |
| Saved where | localStorage | The cart survives a refresh |
| Quantity limits | 1 to 20 per item | Stops mistakes and huge numbers |
| Total | Calculated, never stored | One source of truth |
| Order form | Validates, then shows a confirmation | No server yet |
Stage 1: The CartContext
Write a React context for a shopping cart in src/context/CartContext.jsx.
Requirements:
- Cart state is an array of { id, qty }. Use useReducer. Actions: add, increase, decrease, remove, clear.
- Quantity per item is at most 20. Decreasing to 0 removes the line.
- Load the initial cart from localStorage key 'brew-and-bean-cart' (wrap in try/catch, ignore invalid data, keep only ids that exist in `menu` from '../data/menu.js'). Save the cart to localStorage in a useEffect (also in try/catch).
- The provider shares: lines (each line has id, qty and the full menu item), totalItems, totalPrice (sum of qty * item.price), addItem(id), increase(id), decrease(id), removeItem(id), clearCart().
- Export CartProvider and a hook useCart that throws a clear error if used outside the provider.
Plain JavaScript with JSX, no extra libraries. Explain nothing, code only.What to expect: Check: the cart stores only id and qty (not names or prices); localStorage reads and writes are in try/catch; invalid saved data is filtered out; totalPrice is calculated, not stored; the hook throws outside the provider. Also check that AI did not invent an npm install for a state library.
import { createContext, useContext, useEffect, useMemo, useReducer } from 'react'
import { menu } from '../data/menu.js'
const STORAGE_KEY = 'brew-and-bean-cart'
const MAX_QTY = 20
const CartContext = createContext(null)
// The cart is a list of { id, qty }. Names and prices come from the menu.
function cartReducer(cart, action) {
switch (action.type) {
case 'add': {
const existing = cart.find((line) => line.id === action.id)
if (!existing) {
return [...cart, { id: action.id, qty: 1 }]
}
return cart.map((line) =>
line.id === action.id
? { ...line, qty: Math.min(line.qty + 1, MAX_QTY) }
: line,
)
}
case 'increase':
return cart.map((line) =>
line.id === action.id
? { ...line, qty: Math.min(line.qty + 1, MAX_QTY) }
: line,
)
case 'decrease':
return cart
.map((line) =>
line.id === action.id ? { ...line, qty: line.qty - 1 } : line,
)
.filter((line) => line.qty > 0)
case 'remove':
return cart.filter((line) => line.id !== action.id)
case 'clear':
return []
default:
return cart
}
}
// Read the saved cart, ignoring anything that is missing or invalid.
function loadSavedCart() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY))
if (!Array.isArray(saved)) return []
return saved.filter(
(line) =>
menu.some((item) => item.id === line.id) &&
Number.isInteger(line.qty) &&
line.qty > 0 &&
line.qty <= MAX_QTY,
)
} catch {
return []
}
}
export function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, [], loadSavedCart)
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cart))
} catch {
// Storage can be blocked (private mode). The cart still works in memory.
}
}, [cart])
const value = useMemo(() => {
const lines = cart.map((line) => ({
...line,
item: menu.find((item) => item.id === line.id),
}))
return {
lines,
totalItems: lines.reduce((sum, line) => sum + line.qty, 0),
totalPrice: lines.reduce((sum, line) => sum + line.qty * line.item.price, 0),
addItem: (id) => dispatch({ type: 'add', id }),
increase: (id) => dispatch({ type: 'increase', id }),
decrease: (id) => dispatch({ type: 'decrease', id }),
removeItem: (id) => dispatch({ type: 'remove', id }),
clearCart: () => dispatch({ type: 'clear' }),
}
}, [cart])
return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}
// eslint-disable-next-line react-refresh/only-export-components
export function useCart() {
const context = useContext(CartContext)
if (!context) {
throw new Error('useCart must be used inside <CartProvider>')
}
return context
}The third argument of useReducer (loadSavedCart) runs once, on the first render, to build the starting cart.
A few things worth understanding in this file:
- `cartReducer` never changes the old array. It returns a new one using map, filter and the spread operator [...cart]. React compares old and new and only redraws when the value is a new object.
- `useMemo` builds the shared object only when cart changes, so components that read the context do not redraw for no reason.
- `loadSavedCart` does not trust localStorage. Anyone can edit it in the browser tools, and an old version of your site may have saved a different shape. We keep only lines that have a real menu id and a whole-number quantity between 1 and 20.
- Total price is calculated from the menu prices on every change. We never store it.
Never trust the browser for money
The total on this page is for display only. In a real shop, never trust a price sent from the browser: the server must look up the prices and calculate the total itself. The API lessons later in this track do exactly this. Anyone can change JavaScript in their own browser.
Stage 2: Wire it into the app
The provider must wrap everything that uses the cart. Edit src/main.jsx to add CartProvider inside BrowserRouter:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { CartProvider } from './context/CartContext.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<CartProvider>
<App />
</CartProvider>
</BrowserRouter>
</StrictMode>,
)Only the import and the CartProvider wrapper are new.
Now give each menu card an Add button. useCart() returns addItem, and the button calls it with the item id. The aria-label includes the item name, because a screen reader user who jumps from button to button would otherwise hear "Add, Add, Add".
import { useCart } from '../context/CartContext.jsx'
import { categoryLabels } from '../data/menu.js'
import { formatPrice } from '../utils/format.js'
export default function MenuCard({ item }) {
const { addItem } = useCart()
return (
<article className="menu-card">
<div className="swatch" aria-hidden="true">
{item.name.charAt(0)}
</div>
<div className="menu-card-body">
<span className="category-tag">{categoryLabels[item.category]}</span>
<h3>{item.name}</h3>
<p>{item.description}</p>
<div className="menu-card-footer">
<span className="price">{formatPrice(item.price)}</span>
<button
type="button"
className="btn"
aria-label={`Add ${item.name} to cart`}
onClick={() => addItem(item.id)}
>
Add
</button>
</div>
</div>
</article>
)
}Compared to the last lesson: a new import, the useCart hook, and the button in the footer.
Show the cart count in the header:
import { Link, NavLink } from 'react-router-dom'
import { useCart } from '../context/CartContext.jsx'
export default function Header() {
const { totalItems } = useCart()
return (
<header className="site-header">
<div className="container header-inner">
<Link to="/" className="brand">
Brew & Bean
</Link>
<nav aria-label="Main">
<ul className="nav-list">
<li>
<NavLink to="/" end>
Home
</NavLink>
</li>
<li>
<NavLink to="/menu">Menu</NavLink>
</li>
<li>
<NavLink to="/cart">
Cart
{totalItems > 0 && <span className="cart-badge">{totalItems}</span>}
</NavLink>
</li>
</ul>
</nav>
</div>
</header>
)
}totalItems > 0 && (...) shows the badge only when something is in the cart.
Stage 3: The order form
The form has three fields: name, mobile number and pickup time. We validate on submit and show messages next to the fields. Points to notice:
- Every input has a visible <label> connected with htmlFor and id. Placeholders are not labels.
- noValidate turns off the browser's own pop-up messages so we can show clear messages in our own style. We still validate in JavaScript.
- The phone check ^[6-9]\d{9}$ means: exactly ten digits, starting with 6, 7, 8 or 9. That is the format of an Indian mobile number.
- aria-invalid and aria-describedby tell screen readers that a field has an error and where to find the message.
- type="tel" with inputMode="numeric" opens the number pad on phones.
Write a React component `OrderForm` in src/components/OrderForm.jsx with a prop `onSubmit(details)`.
Fields: name (text, required, at least 2 characters), phone (Indian mobile number: exactly 10 digits starting with 6-9), pickup time (a select with 'As soon as possible', 'In 15 minutes', 'In 30 minutes', 'In 1 hour').
On submit: preventDefault, validate, show an error under each invalid field, and only call onSubmit({ name, phone, pickup }) with trimmed values if everything is valid.
Accessibility: every field has a <label htmlFor>, errors are linked with aria-describedby, invalid fields have aria-invalid.
Use form noValidate. Class names: order-form, field, error, btn. No form libraries.What to expect: Check: labels are real <label> elements; the regex is anchored (^ and $); the form does not call onSubmit when invalid; values are trimmed. Try typing letters in the phone box and 9 digits to see the error.
import { useState } from 'react'
const pickupOptions = [
'As soon as possible',
'In 15 minutes',
'In 30 minutes',
'In 1 hour',
]
function validate({ name, phone }) {
const errors = {}
if (name.trim().length < 2) {
errors.name = 'Enter your name (at least 2 letters).'
}
if (!/^[6-9]\d{9}$/.test(phone.trim())) {
errors.phone = 'Enter a 10-digit mobile number starting with 6, 7, 8 or 9.'
}
return errors
}
export default function OrderForm({ onSubmit }) {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [pickup, setPickup] = useState(pickupOptions[0])
const [errors, setErrors] = useState({})
function handleSubmit(event) {
event.preventDefault()
const found = validate({ name, phone })
setErrors(found)
if (Object.keys(found).length > 0) return
onSubmit({ name: name.trim(), phone: phone.trim(), pickup })
}
return (
<form className="order-form" onSubmit={handleSubmit} noValidate>
<h2>Your details</h2>
<div className="field">
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
value={name}
onChange={(event) => setName(event.target.value)}
aria-invalid={Boolean(errors.name)}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<p className="error" id="name-error">
{errors.name}
</p>
)}
</div>
<div className="field">
<label htmlFor="phone">Mobile number</label>
<input
id="phone"
name="phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
maxLength={10}
value={phone}
onChange={(event) => setPhone(event.target.value)}
aria-invalid={Boolean(errors.phone)}
aria-describedby={errors.phone ? 'phone-error' : undefined}
/>
{errors.phone && (
<p className="error" id="phone-error">
{errors.phone}
</p>
)}
</div>
<div className="field">
<label htmlFor="pickup">Pickup time</label>
<select
id="pickup"
name="pickup"
value={pickup}
onChange={(event) => setPickup(event.target.value)}
>
{pickupOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<button type="submit" className="btn">
Place order
</button>
</form>
)
}Each input is a controlled input: its value comes from state and onChange updates the state.
Stage 4: The confirmation and the Cart page
After a valid order we show a thank-you screen. The Cart page keeps a copy of the order in its own state before it empties the cart. If it did it the other way round, the summary would be empty.
import { Link } from 'react-router-dom'
import { formatPrice } from '../utils/format.js'
export default function OrderConfirmation({ order }) {
return (
<section className="container page">
<div className="confirmation">
<h1>Thank you, {order.name}!</h1>
<p>
Your order will be ready for pickup{' '}
<strong>{order.pickup.toLowerCase()}</strong> at our Indiranagar shop.
</p>
<ul className="summary-list">
{order.lines.map(({ id, qty, item }) => (
<li key={id}>
<span>
{qty} x {item.name}
</span>
<span>{formatPrice(item.price * qty)}</span>
</li>
))}
</ul>
<p className="summary-total">
<span>Total</span>
<span>{formatPrice(order.total)}</span>
</p>
<p className="muted">
This is a demo. Your order has not been sent anywhere yet, because the
website is not connected to a server.
</p>
<Link to="/menu" className="btn">
Back to the menu
</Link>
</div>
</section>
)
}The grey note tells the truth: nothing has been sent. Never show a fake success message in a real site.
The Cart page has three states: confirmation (after ordering), empty (nothing in the cart) and the normal view. Each early return handles one state, which keeps the main layout easy to read.
import { useState } from 'react'
import { Link } from 'react-router-dom'
import OrderForm from '../components/OrderForm.jsx'
import OrderConfirmation from '../components/OrderConfirmation.jsx'
import { useCart } from '../context/CartContext.jsx'
import { formatPrice } from '../utils/format.js'
export default function Cart() {
const { lines, totalPrice, increase, decrease, removeItem, clearCart } =
useCart()
const [confirmation, setConfirmation] = useState(null)
function handlePlaceOrder(details) {
// Keep a copy of the order before the cart is emptied.
setConfirmation({ ...details, lines, total: totalPrice })
clearCart()
}
if (confirmation) {
return <OrderConfirmation order={confirmation} />
}
if (lines.length === 0) {
return (
<section className="container page empty">
<h1>Your cart is empty</h1>
<p>Add something from the menu and it will show up here.</p>
<Link to="/menu" className="btn">
Browse the menu
</Link>
</section>
)
}
return (
<section className="container page">
<h1>Your cart</h1>
<div className="cart-layout">
<div>
<ul className="cart-list">
{lines.map(({ id, qty, item }) => (
<li key={id} className="cart-line">
<span className="cart-line-name">{item.name}</span>
<div className="qty">
<button
type="button"
className="qty-btn"
aria-label={`Decrease quantity of ${item.name}`}
onClick={() => decrease(id)}
>
-
</button>
<span>{qty}</span>
<button
type="button"
className="qty-btn"
aria-label={`Increase quantity of ${item.name}`}
onClick={() => increase(id)}
>
+
</button>
</div>
<span className="cart-line-price">
{formatPrice(item.price * qty)}
</span>
<button
type="button"
className="link-button"
aria-label={`Remove ${item.name} from cart`}
onClick={() => removeItem(id)}
>
Remove
</button>
</li>
))}
</ul>
<div className="cart-total">
<span>Total</span>
<span>{formatPrice(totalPrice)}</span>
</div>
<button type="button" className="link-button" onClick={clearCart}>
Empty the cart
</button>
</div>
<OrderForm onSubmit={handlePlaceOrder} />
</div>
</section>
)
}The aria-labels on the quantity buttons say which item they change.
Stage 5: The styles
Add these rules to the end of src/index.css:
/* Cart badge in the header */
.cart-badge {
display: inline-block;
min-width: 1.4em;
margin-left: 6px;
padding: 0 6px;
border-radius: 999px;
background: var(--brand);
color: #ffffff;
font-size: 0.8rem;
text-align: center;
}
/* Cart page */
.empty {
text-align: center;
padding-top: 64px;
}
.cart-layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 32px;
align-items: start;
margin-top: 24px;
}
.cart-list {
list-style: none;
margin: 0;
padding: 0;
}
.cart-line {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto;
gap: 16px;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.cart-line-name {
font-weight: 600;
}
.cart-line-price {
font-weight: 600;
}
.qty {
display: flex;
align-items: center;
gap: 8px;
}
.qty-btn {
width: 32px;
height: 32px;
border: 1px solid var(--brand);
border-radius: 6px;
background: var(--surface);
color: var(--brand);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.link-button {
background: none;
border: 0;
padding: 0;
color: var(--brand);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.cart-total {
display: flex;
justify-content: space-between;
padding: 16px 0;
font-size: 1.2rem;
font-weight: 700;
}
/* Order form */
.order-form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
}
.order-form h2 {
margin-top: 0;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
margin-bottom: 4px;
font-weight: 600;
}
.field input,
.field select {
width: 100%;
padding: 10px 12px;
font: inherit;
color: var(--text);
background: #ffffff;
border: 1px solid var(--field-border);
border-radius: 8px;
}
.field input[aria-invalid='true'] {
border-color: var(--danger);
}
.error {
margin: 4px 0 0;
font-size: 0.9rem;
color: var(--danger);
}
/* Order confirmation */
.confirmation {
max-width: 600px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
}
.confirmation h1 {
margin-top: 0;
}
.summary-list {
list-style: none;
margin: 24px 0 0;
padding: 0;
}
.summary-list li,
.summary-total {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.summary-total {
font-weight: 700;
}
.muted {
color: var(--muted);
}The cart rows use a four-column grid. That is fine on a desktop; we fix it for phones in the next lesson.
Test it
What to check
- 1On the Menu page click Add on Espresso twice and on Butter Croissant once. The header should show Cart with a badge showing 3.
- 2Open the Cart. You should see Espresso with quantity 2 and ₹240, Butter Croissant with quantity 1 and ₹150, and a total of ₹390.
- 3Click + on Espresso: quantity 3, total ₹510. Click - twice: back to 1. Click - once more: Espresso disappears.
- 4Refresh the page (F5). The cart is still there.
- 5Click Place order with empty fields. Two error messages appear.
- 6Enter a name and the phone number 12345. The phone error stays. Change it to 9876543210. Click Place order.
- 7You should see "Thank you, [your name]!", the order summary and the total, and the header badge disappears.
- 8Open the Cart again: "Your cart is empty" with a link to the menu.
Cart page:
Espresso - 2 + ₹240 Remove
Butter Croissant - 1 + ₹150 Remove
Total ₹390
[Your details form on the right: Name, Mobile number, Pickup time, Place order]You can also inspect the saved data. In the browser press F12, open the Application tab (Storage tab in Firefox), choose Local Storage and look for brew-and-bean-cart. It is a short list like [{"id":"espresso","qty":2}]. Now edit it to something invalid (for example [{"id":"hacked","qty":-5}]) and refresh. The site should still load with an empty cart. That is the validation in loadSavedCart working.
git add .
git commit -m "Add cart context, order form and confirmation"Edge cases to try on your own
Try to break it
- Add the same item 25 times. Quantity stops at 20.
- Open the site in two tabs, add an item in one and refresh the other. The cart is shared through localStorage.
- Type spaces only in the name field. You get the error.
- Enter a phone number with 11 digits. The input stops at 10 characters.
- Block site data in the browser or use a private window. The cart still works until you close the tab.
Review this React cart code for bugs, edge cases and security problems. The app is a coffee shop website with no backend yet.
<code>
[paste CartContext.jsx and Cart.jsx]
</code>
List only real problems (not style preferences), most serious first. For each, say how to reproduce it and give the smallest fix. If there are no real problems in a category, say so.What to expect: Good finds include: unlimited quantity, trusting localStorage, rounding errors with prices, and missing empty states. Be suspicious of advice to add libraries such as Redux for a cart this small.
Try it: Add a note to the order
Add an optional "Note for the barista" field (for example "less sugar") to the form, limited to 120 characters. Show it on the confirmation page.
Try it: Stretch: write a test for the reducer
Export cartReducer from CartContext.jsx and write tests for it with Vitest: adding a new item, adding the same item twice, the quantity cap at 20, and decreasing to zero. Ask your AI to explain the test output.
Key takeaways
- Context shares data like a cart across many components; a reducer keeps all the change rules in one place.
- Store the minimum (id and quantity) and calculate the rest (names, prices, totals) from the menu.
- Never trust saved or browser-side data: validate what you read from localStorage, and let the server calculate real prices.
- Forms need real labels, clear error messages,
aria-invalidand sensible input types. - Handle every state of a page: empty, normal and confirmed.
- Be honest in the interface: this demo says the order has not been sent.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why does each cart line store only { id, qty } and not the name and price?
2. What is the job of a reducer such as cartReducer?
3. The saved cart in localStorage contains [{"id":"hacked","qty":-5}]. What does loadSavedCart do?
4. Why is the cart total shown in the browser not enough for a real shop?
5. What does aria-describedby={errors.phone ? 'phone-error' : undefined} do?
6. Why does handlePlaceOrder copy the lines into state before calling clearCart()?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.