Week 7
Last week, our page asked a server for a list and showed it. This week we're building the thing every store has: a cart. You'll pick a size, add it, change how many you want, and remove it. By the end, your page will have a small working practice cart, and you'll be able to point at the exact line of code that runs for each of those clicks.
The products and prices are made up, and the cart only lives in React state (so refreshing the page empties it). Adding something doesn't reserve a shirt, place an order, or charge anyone. We'll get to the real store cart later.
Getting started
The complete practice files are included at the end of this lesson.
Save and commit your Week 6 work. Open a terminal at the top of practice-page and make a new branch from it:
git status
git switch -c cart-practice
Copy the supplied App.jsx, App.css, products.js, and cart.mjs into react-page/src. These replace the Week 6 practice screen on this branch (your Week 6 work is still saved in Git, so nothing is lost). Keep main.jsx and styles.css.
The .mjs ending just means "this is a JavaScript module," a file that exports things for other files to import. Keep the exact filename, since App.jsx imports it by that name.
Start the server from react-page:
cd react-page
npm run dev
Open the address the terminal prints. You should see Practice cart, a Size dropdown, an Add to cart button, and an empty cart. If npm isn't found, or a push later asks you to sign in, check the Setup page.
Before reading any code, try it once. Add the Small shirt twice, change its quantity, then remove it. Now we'll go find the code that did each of those.
What a cart has to know
A product list and a cart sound like the same thing, but they do pretty different jobs. The product list describes what you can buy. The cart describes what this person picked and how many.
So before writing a cart, you have to answer a few questions:
- Which exact thing did they pick? (Not just "a shirt," but which size)
- How many do they want?
- What happens if they add the same thing twice?
- What if the thing they picked isn't available?
- Who is allowed to change the cart, and where does it live?
Pretty much everything in this lesson is an answer to one of those questions.
Products and variants
Open products.js. The first product looks like this:
{
id: "demo-shirt",
name: "Practice T-shirt",
variants: [
{ id: "demo-shirt-small", label: "Small", priceMinor: 2400, currency: "USD", available: true },
{ id: "demo-shirt-medium", label: "Medium", priceMinor: 2400, currency: "USD", available: false },
],
}
A product is the item, like a T-shirt. A variant is one specific option of that product that you can actually buy, like the Small T-shirt. Both sizes belong to demo-shirt, but each one has its own variant ID.
That's the answer to question 1. The cart needs to know the variant, not just the product. If we only saved demo-shirt, a Small and a Medium would look like the same thing, and someone ordering one of each would get two of whatever we guessed.
available is a Boolean (true or false). Medium starts as false, so you can't pick it yet.
priceMinor is the price in cents, so 2400 means $24.00. We store whole cents because decimals in JavaScript can get weird (try 0.1 + 0.2 in the Console sometime). These field names are our practice format, not Fourthwall's. The real API has its own fields, and you always go by its docs.
The cart line remembers the variant ID. Adding Small again doesn't make a second line, it just bumps the quantity on the one we already have.
Remembering the selected size
In App.jsx, look at the top of App:
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0].id);
const [cart, setCart] = useState([]);
const [error, setError] = useState("");
Three pieces of state:
selectedVariantIdis the size picked in the dropdown. It starts as the first variant, Small.cartis the list of things added so far. It starts as an empty array.erroris a message to show if something can't be added.
Now find the select:
<select id="variant" value={selectedVariantId} onChange={handleVariantChange}>
{product.variants.map((variant) => (
<option key={variant.id} value={variant.id} disabled={!variant.available}>
{variant.label}, {formatPrice(variant.priceMinor)}
{!variant.available && " (unavailable)"}
</option>
))}
</select>
This is the same controlled input from Week 5's search box, just a dropdown this time. value comes from state, and handleVariantChange puts event.target.value back into state.
Each option's value is the variant ID, and the text between the tags is the label people read. That way we could rename "Small" to "S" and the cart wouldn't care, because the ID stays the same.
! flips a Boolean, so an unavailable variant gives disabled={true}. Same idea with && from Week 5: the "(unavailable)" text only shows up when the variant isn't available.
What goes into the cart
Click Add to cart once. The cart now holds one object that looks like this:
{
variantId: "demo-shirt-small",
name: "Practice T-shirt",
label: "Small",
quantity: 1,
unitPriceMinor: 2400,
currency: "USD",
}
variantId says which option, and quantity says how many. unitPriceMinor is the price of one. The cart is just an array of these, one per variant.
The cart display uses .map() like our interest cards did, and each line's key is item.variantId. That's the stable ID React uses to tell lines apart when one gets removed.
Passing a function to a child
So far, props have carried text, like a card's title. Props can also carry a function. This is how a child component tells its parent that something happened.
function AddButton(props) {
function handleClick() {
props.onAdd(props.variantId);
}
return <button type="button" onClick={handleClick}>Add to cart</button>;
}
And here's where App uses it:
<AddButton variantId={selectedVariantId} onAdd={handleAdd} />
Why not just give AddButton its own cart? Because tons of parts of the page need the same cart. The product controls add to it, the cart list shows it, and later a checkout button reads it. If each one kept its own copy, they'd disagree. So the cart lives in the closest parent that all of them share (here that's App), and the children get what they need through props. React's docs call this "lifting state up." In the merch project, one team might build the product controls and another the cart, and this is how those pieces will talk to each other.
onAdd is a name we made up. React doesn't know it means anything. onClick on the real <button> is the built-in one that listens for the browser's click.
Keep it as onAdd={handleAdd}. If you wrote onAdd={handleAdd(selectedVariantId)}, it would run the function right away while React is drawing the page, before anyone clicked. Same mistake as onChange={handleSearch()} in Week 5.
Following the Add button
Find handleAdd in App.jsx:
function handleAdd(variantId) {
try {
const updatedCart = addItem(cart, product, variantId);
setCart(updatedCart);
setError("");
} catch (failure) {
setError(failure.message);
}
}
It gets the ID from AddButton, asks addItem for the next version of the cart, and gives that to setCart. If addItem throws an error, we land in catch and show its message instead. The cart we already had stays on screen. (This is the same try/catch from last week's request.)
Now open cart.mjs and find addItem. Ignore the checks at the top for a second and read it in this order:
- Find the variant with that ID in the product's
variants. - If it doesn't exist or isn't available, throw an error.
- Look for a cart line that already has that variant ID.
- If there is one, add 1 to its quantity.
- If there isn't, return a new array with a new line at the end.
That's questions 3 and 4 from earlier. Same variant twice means a bigger quantity, and an unavailable variant gets rejected.
You might wonder why we check availability in addItem when the dropdown already disables Medium. The disabled option helps the person using the page. The check in the function stops any other code from adding something it shouldn't. (A real store checks again on its server, since anyone can mess with what a browser sends.)
.find() goes through an array and gives back the first item that passes your test. If nothing passes, you get undefined.
Here's the line that adds a new item:
js
return [...cart, newItem];
...cart is spread syntax. It copies everything from the old array into a new one, then newItem goes at the end. We make a new array instead of calling cart.push(newItem), because push changes the array React is already holding. React compares the old and new state to decide whether to render again, so if you edit the old array in place, the page might not update at all.
Removing an item
Find removeItem in cart.mjs:
return cart.filter((item) => item.variantId !== variantId);
This is .filter() from Week 5. It makes a new array with only the items that pass the test. !== means "is not equal to." So read it as "keep every line whose variant ID isn't the one we're removing."
(item) => item.variantId !== variantId is an arrow function, a shorter way to write a function. item is the parameter, and whatever comes after the arrow is what it returns. It does the same job as the named matchesSearch function we passed to .filter() in Week 5. We just wrote it right inside the call because we only need it there.
Now look at the Remove button in App.jsx:
<button type="button" onClick={() => handleRemove(item.variantId)}>
Remove
</button>
Each line needs to tell handleRemove which ID it is, so we wrap the call in a tiny arrow function. That arrow waits for the click. onClick={handleRemove(item.variantId)} without the arrow would remove the item while the page is drawing, which is pretty much never what you want.
Changing a quantity
Find handleQuantity in App.jsx. The quantity dropdown gives us text, like "3", even though it looks like a number. Number(value) turns that into the number 3 before we pass it to changeQuantity.
The dropdown goes from 1 to 10. There's no 0, because the Remove button already does that job. (1 to 10 is our practice rule. A real store's API has its own limits.)
Open changeQuantity in cart.mjs. After its checks, it does this:
return cart.map((item) => {
if (item.variantId === variantId) {
return { ...item, quantity: quantity };
}
return item;
});
.map() visits every line and returns one result for each. For the line we're changing, { ...item, quantity: quantity } makes a new object with all the old fields copied in, and then replaces quantity. Every other line gets returned the way it was.
In Week 5, .map() turned data into cards. Here it turns data into new data. Same method, it just depends on what you return.
Don't write item.quantity = 3. That edits an object React is already holding, which is the same problem as push.
Set the Small quantity to 3. The subtotal should go from $24.00 to $72.00. subtotal(cart) adds up each line's price times its quantity, and formatPrice turns cents into dollars for display. We don't keep the subtotal in state. It's calculated from the cart every render, the same way Week 5 calculated filteredInterests from the search text.
The page calls it an estimated item subtotal on purpose. It doesn't include shipping, tax, or discounts, and no payment service agreed to charge it. The real total comes from checkout.
Try two sizes
Now let's see why the variant ID matters. In products.js, change Medium's available to true and save. Refresh so the cart starts empty, then:
- Add Small once and Medium once. You should see two separate lines.
- Add Small again. Only Small's quantity should go up, to 2.
- Change Medium's quantity to 3. Small should stay at 2.
- Remove Small. Medium should still be there with quantity 3.
If Small and Medium ever end up on one line, something is matching on the product ID instead of the variant ID.
Add a Clear cart button
Last thing for the cart. We want a button that empties it. Add this inside App, under handleRemove:
function handleClear() {
setCart([]);
setError("");
}
<h2 id="cart-heading">Your cart</h2>
<button type="button" onClick={handleClear} disabled={cart.length === 0}>
Clear cart
</button>
Clearing the cart is just setting it to an empty array. We don't touch products at all, since the things you can buy didn't change, only what you picked. disabled={cart.length === 0} greys the button out when there's nothing to clear.
Save, add a couple of things, and click Clear cart. The cart should say it's empty, and the button should go grey.
When the cart lives on a server
Our cart runs instantly in the browser. The merch site's cart will be different, because Fourthwall holds it. Remember these from last week?
| Endpoint | What it does |
|---|---|
POST /v1/carts/{cartId}/add |
Add items to a cart |
POST /v1/carts/{cartId}/change |
Change item quantities |
POST /v1/carts/{cartId}/remove |
Remove items |
Their request bodies look pretty familiar. Each one sends a list of items, and each item has a variantId and a quantity. So today's idea (the cart cares about the variant, not the product) carries straight over.
What comes back is the whole cart. Each item has a variant (with its own id, name, and unitPrice) and a quantity. There's no subtotal field in the cart response. That's one more reason not to treat our estimate as the real total.
A few things change once the cart is on a server:
- The click doesn't update the cart right away. It sends a request, and you wait.
- The server can say no (out of stock, bad quantity), so you show the error and keep the last cart that actually worked.
- You update the page from the cart the server sends back, not from the number you hoped it would be.
- While a request is going, you disable the cart buttons so two changes don't race each other. (Same idea as disabling Load interests last week.)
And a cart still isn't an order, whether it's ours or Fourthwall's. Next week, we'll follow it from the cart into checkout and see what happens to payment and orders.
When something goes wrong
| What you see | What to check |
|---|---|
| The import fails | Keep cart.mjs and products.js in src, and check the file extensions match the imports. |
| The dropdown changes, but Add uses the wrong size | The select should read and set selectedVariantId, and AddButton should get that same ID. |
| Small and Medium become one line | Match lines by variantId, and use item.variantId as the key. |
| An unavailable size gets added | Check both disabled on the option and the availability check in addItem. |
| The cart changes before you click | Pass the function (onAdd={handleAdd}), and wrap Remove in () => handleRemove(item.variantId). |
| Clicking does nothing to the cart | Make a new array or object and call setCart. Editing the old one with push or item.quantity = 3 won't update the page. |
| Changing quantity shows an error | Convert the dropdown value with Number(value), and keep it from 1 to 10. |
| An old error stays after something works | Call setError("") after a successful action. |
| The price is 100 times too big | The data is in cents. Divide by 100 only once, when displaying. |
| The cart is empty after refreshing | That's expected. This cart only lives in React state. |
What's next
Before next week, set Medium back to false if you want to match the reference (or keep it, your call). Run npm run build from react-page to make sure everything still bundles, then commit with a message like Add a practice cart and push the branch. If GitHub asks you to sign in, use the GitHub CLI steps on the Setup page.
A passing build only means the code bundles. Clicking through the cart is how you know it actually works, so do both.
Next week, we'll take a cart into Fourthwall's hosted checkout and see what happens to payment and orders.
Resources
Everything needed for today's code and explanations is above. These are extra examples or references to return to after the exercise.
- React: Sharing State Between Components
- React: Responding to Events
- React: Updating Arrays in State
- React: Updating Objects in State
- React:
<select> - MDN: Array.find
- MDN: Array.filter
- MDN: Array.map
- MDN: Array.some
- MDN: Spread syntax
- MDN: Arrow functions
- MDN: Number()
- MDN: Number.toLocaleString
- Fourthwall: Add to Cart
- Fourthwall: Change Cart Quantity
- Fourthwall: Remove from Cart
- Fourthwall: Storefront API schema
Complete practice files
Copy these files into the paths shown below. They are the same files used by the lesson. Keep the filenames and imports together. Medium starts unavailable here, and turning it on is part of the lesson.
src/App.jsx
import { useState } from "react";
import { products } from "./products.js";
import { addItem, changeQuantity, removeItem, subtotal } from "./cart.mjs";
import "./App.css";
const product = products[0];
const quantities = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function formatPrice(priceMinor) {
return (priceMinor / 100).toLocaleString("en-US", {
style: "currency",
currency: "USD",
});
}
function AddButton(props) {
function handleClick() {
props.onAdd(props.variantId);
}
return <button type="button" onClick={handleClick}>Add to cart</button>;
}
export default function App() {
const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0].id);
const [cart, setCart] = useState([]);
const [error, setError] = useState("");
function handleVariantChange(event) {
setSelectedVariantId(event.target.value);
setError("");
}
function handleAdd(variantId) {
try {
const updatedCart = addItem(cart, product, variantId);
setCart(updatedCart);
setError("");
} catch (failure) {
setError(failure.message);
}
}
function handleQuantity(variantId, value) {
try {
const updatedCart = changeQuantity(cart, variantId, Number(value));
setCart(updatedCart);
setError("");
} catch (failure) {
setError(failure.message);
}
}
function handleRemove(variantId) {
setCart(removeItem(cart, variantId));
setError("");
}
function handleClear() {
setCart([]);
setError("");
}
return (
<main>
<h1>Practice cart</h1>
<p>These are made-up products. This cart stays in memory and clears when you refresh.</p>
<section className="interest-card" aria-labelledby="product-heading">
<h2 id="product-heading">{product.name}</h2>
<label htmlFor="variant">Size</label>
<select id="variant" value={selectedVariantId} onChange={handleVariantChange}>
{product.variants.map((variant) => (
<option key={variant.id} value={variant.id} disabled={!variant.available}>
{variant.label}, {formatPrice(variant.priceMinor)}
{!variant.available && " (unavailable)"}
</option>
))}
</select>
<AddButton variantId={selectedVariantId} onAdd={handleAdd} />
</section>
{error !== "" && <p role="alert">{error}</p>}
<section aria-labelledby="cart-heading">
<h2 id="cart-heading">Your cart</h2>
<button type="button" onClick={handleClear} disabled={cart.length === 0}>
Clear cart
</button>
{cart.length === 0 && <p>Your cart is empty.</p>}
<div className="interest-list">
{cart.map((item) => (
<article className="interest-card" key={item.variantId}>
<h3>{item.name} ยท {item.label}</h3>
<p>{formatPrice(item.unitPriceMinor)} each</p>
<label htmlFor={"quantity-" + item.variantId}>Quantity</label>
<select
id={"quantity-" + item.variantId}
value={item.quantity}
onChange={(event) => handleQuantity(item.variantId, event.target.value)}
>
{quantities.map((quantity) => (
<option key={quantity} value={quantity}>{quantity}</option>
))}
</select>
<button
type="button"
className="remove-button"
onClick={() => handleRemove(item.variantId)}
aria-label={"Remove " + item.name + ", " + item.label}
>
Remove
</button>
</article>
))}
</div>
<p role="status" className="cart-subtotal">Estimated item subtotal: {formatPrice(subtotal(cart))}</p>
<p>This estimate excludes shipping, taxes, and discounts. Adding an item does not place or pay for an order.</p>
</section>
</main>
);
}
src/App.css
section + section {
margin-top: 24px;
}
label {
display: block;
font-weight: 600;
margin: 16px 0 8px;
}
select {
display: block;
width: 100%;
font: inherit;
color: inherit;
background: white;
padding: 12px;
border: 1px solid #64748b;
border-radius: 8px;
margin-bottom: 16px;
}
select:focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 3px;
}
.remove-button {
color: #1d4ed8;
background: #eff6ff;
border: 1px solid #1d4ed8;
}
.cart-subtotal {
font-size: 1.2rem;
font-weight: 700;
}
[role="alert"] {
color: #991b1b;
border-left: 4px solid #b91c1c;
padding: 12px;
background: #fef2f2;
}
src/products.js
// Made-up products for the local cart exercise. Prices are USD cents.
export const products = [
{
id: "demo-shirt",
name: "Practice T-shirt",
variants: [
{ id: "demo-shirt-small", label: "Small", priceMinor: 2400, currency: "USD", available: true },
{ id: "demo-shirt-medium", label: "Medium", priceMinor: 2400, currency: "USD", available: false },
],
},
{
id: "demo-stickers",
name: "Practice stickers",
variants: [
{ id: "demo-stickers-single", label: "Single sticker", priceMinor: 300, currency: "USD", available: true },
{ id: "demo-stickers-pack", label: "Pack of three", priceMinor: 800, currency: "USD", available: true },
],
},
];
src/cart.mjs
// Local USD-only exercise. Hosted checkout calculates the final order amount.
const MAX_QUANTITY = 10;
function checkQuantity(quantity) {
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
throw new Error("Choose a whole-number quantity from 1 to 10.");
}
}
function checkPrice(priceMinor, currency) {
if (!Number.isSafeInteger(priceMinor) || priceMinor < 0) {
throw new Error("The practice price must be a nonnegative whole number of cents.");
}
if (currency !== "USD") {
throw new Error("This practice cart uses USD only.");
}
}
function checkCart(cart) {
if (!Array.isArray(cart)) {
throw new Error("The cart must be an array.");
}
for (const item of cart) {
if (
!item ||
typeof item.variantId !== "string" ||
typeof item.name !== "string" ||
typeof item.label !== "string"
) {
throw new Error("A cart item is missing its details.");
}
checkQuantity(item.quantity);
checkPrice(item.unitPriceMinor, item.currency);
}
}
export function addItem(cart, product, variantId) {
checkCart(cart);
if (!product || typeof product.name !== "string" || !Array.isArray(product.variants)) {
throw new Error("Choose a valid product.");
}
const variant = product.variants.find((option) => option.id === variantId);
if (!variant || typeof variant.id !== "string" || typeof variant.label !== "string") {
throw new Error("Choose a valid variant.");
}
if (variant.available !== true) {
throw new Error("That variant is unavailable.");
}
checkPrice(variant.priceMinor, variant.currency);
const existingItem = cart.find((item) => item.variantId === variantId);
if (existingItem) {
return changeQuantity(cart, variantId, existingItem.quantity + 1);
}
return [
...cart,
{
variantId: variant.id,
name: product.name,
label: variant.label,
quantity: 1,
unitPriceMinor: variant.priceMinor,
currency: variant.currency,
},
];
}
export function changeQuantity(cart, variantId, quantity) {
checkCart(cart);
checkQuantity(quantity);
if (!cart.some((item) => item.variantId === variantId)) {
throw new Error("That item is not in the cart.");
}
return cart.map((item) => {
if (item.variantId === variantId) {
return { ...item, quantity: quantity };
}
return item;
});
}
export function removeItem(cart, variantId) {
checkCart(cart);
return cart.filter((item) => item.variantId !== variantId);
}
export function subtotal(cart) {
checkCart(cart);
let total = 0;
for (const item of cart) {
total = total + item.unitPriceMinor * item.quantity;
}
if (!Number.isSafeInteger(total)) {
throw new Error("The practice subtotal is too large.");
}
return total;
}


