Skip to main content

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. And we're building it together, one piece at a time. You'll pickstart with a size,page addthat it,just changeshows howa many you want, and remove it.shirt. By the end,end yourof pagethe will have a small working practice cart, andmeeting you'll be able to pointpick ata size, add it to a cart, add it again and watch the exactquantity linego ofup, codeand thatremove runsit. forEvery eachone of those clicks.is a few lines you typed yourself.

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

CopyIf theGit suppliedsays that branch already exists, use App.jsx,git App.css,switch products.js, and cart.mjscart-practice intoinstead.

Now we need a starting point. In react-page/src. These, replace App.jsx and App.css with the Weekstarter 6versions practicebelow, screenand onmake thisa branchnew file called products.js. Keep main.jsx and styles.css the way they are. (yourYour Week 6 work is still saved in Git, so nothing is lost).lost.) KeepThe main.jsxsame andthree styles.css.files are also in week-07-starter.zip if you'd rather download than copy.

The

src/products.js

.mjs// endingMade-up justpractice products. Prices are in cents, so 2400 means $24.00.
export const products = [
  {
    id: "thisdemo-shirt",
    isname: a"Practice JavaScriptT-shirt",
    module,variants: [
      { id: "demo-shirt-small", alabel: file"Small", thatpriceMinor: exports2400, thingsavailable: fortrue other},
      files{ toid: import."demo-shirt-medium", Keeplabel: the"Medium", exactpriceMinor: filename,2400, sinceavailable: true },
      { id: "demo-shirt-large", label: "Large", priceMinor: 2400, available: false },
    ],
  },
];

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;
}

button:disabled {
  background: #475569;
  cursor: not-allowed;
}

src/App.jsx imports(starter)

it
import by{ thatproducts name.} from "./products.js";
import "./App.css";

const product = products[0];

function formatPrice(priceMinor) {
  return (priceMinor / 100).toLocaleString("en-US", {
    style: "currency",
    currency: "USD",
  });
}

export default function App() {
  return (
    <main>
      <h1>Practice cart</h1>
      <p>Made-up products. This cart lives in React state, so refreshing the page empties it.</p>

      <section className="interest-card" aria-labelledby="product-heading">
        <h2 id="product-heading">{product.name}</h2>
        <p>{formatPrice(product.variants[0].priceMinor)}</p>
        {/* Step 1: the size dropdown goes here */}
      </section>

      <section aria-labelledby="cart-heading">
        <h2 id="cart-heading">Your cart</h2>
        <p>Your cart is empty.</p>
      </section>
    </main>
  );
}

Start the server from react-page:

cd react-page
npm run dev

Open the address the terminal prints. You should see Practice cart, a SizePractice dropdown,T-shirt anfor Add$24.00, and "Your cart is empty." That's it for now. There's no dropdown and no button yet, because that's what we're about to cart button, and an empty cart.write. 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. AddLeave the Smalldev shirtserver twice, change its quantity, then remove it. Now we'll go findrunning the codewhole thattime. didEvery eachtime ofyou those.save, the page updates by itself.

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. TheThere's firstone productproduct, looksthe like this:

    {
      id: "demo-shirt",
      name: "Practice T-shirt",shirt, variants:and [it {has id: "demo-shirt-small", label: "Small", priceMinor: 2400, currency: "USD", available: true },
        { id: "demo-shirt-medium", label: "Medium", priceMinor: 2400, currency: "USD", available: false },
      ],
    }
    

    Athree productvariants is the item, like a T-shirt.. A variant is one specific option of thata product that you can actually buy, like the Small T-shirt. BothAll three sizes belong to demo-shirt, but each one has its own variant ID.

    That's the answerbig toidea questionfor 1.today: Thethe cart needs to knowremember 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). MediumLarge starts asis false, so youit'll can'tshow pickup itas yet.sold out.

    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). formatPrice in the starter turns cents into dollars, and it's the only place we divide by 100. These field names are our practice format, not Fourthwall's. The real API has its own fields, and you always go by its docs.

    Practice T-shirt (demo-shirt) has two variants: Small, demo-shirt-small, available true, and Medium, demo-shirt-medium, available false. Adding Small makes a practice cart line with variantId demo-shirt-small, quantity 1, unitPriceMinor 2400 and currency USD. Adding Small again raises that same line to quantity 2. 2400 means $24.00 in this exercise, and these fields are our practice format, not Fourthwall's.The demo-shirt product has three variants. Small, demo-shirt-small, available true. Medium, demo-shirt-medium, available true. Large, demo-shirt-large, available false, so it shows as sold out. Adding Small makes a cart line with variantId demo-shirt-small, label Small, quantity 1 and unitPriceMinor 2400. Adding Small again raises that same line to quantity 2. Adding Medium makes a second, separate line. 2400 means $24.00, and these fields are our practice format, not Fourthwall's.

    Here's what we're building toward. The cart line remembers the variant ID. Adding Small again doesn't make a second line, it just bumps the quantityquantity. onAdding theMedium onedoes wemake alreadya have.second line.

    RememberingStep the1: selectedPick a size

    InFirst, App.jsx,the lookpage atneeds to remember which size is picked. That's state, same as the search box in Week 5.

    At the top of AppApp.jsx:, add the useState import above the products import, so the imports look like this:

    import { useState } from "react";
    import { products } from "./products.js";
    

    Next, inside App, right under the export default function App() { line, add the state and a function to update it:

      const [selectedVariantId, setSelectedVariantId] = useState(product.variants[0].id);
    
      constfunction [cart,handleVariantChange(event) setCart]{
        = useState([])setSelectedVariantId(event.target.value);
      const [error, setError] = useState("");}
    

    Three pieces of state:

      selectedVariantId is the size picked in the dropdown. It starts as the first variant,variant's Small.ID, cart isso the list of things added so far. Itpage starts ason an empty array. error is a message to show if something can't be added. Small.

      Now find these two lines in the select:product section:

              <p>{formatPrice(product.variants[0].priceMinor)}</p>
              {/* Step 1: the size dropdown goes here */}
      

      Delete both of them and put this in their place:

              <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)sold out)"}
                  </option>
                ))}
              </select>
              <p>Selected: {selectedVariantId}</p>
      

      Save. You should see a Size dropdown with Small, Medium, and Large, and Large should be greyed out with "(sold out)". Change it to Medium, and the "Selected:" line should change to demo-shirt-medium.

      So what did we just write? This is the same controlled input from Week 5's search box,5, just a dropdown this time. value comes from state, and handleVariantChange puts event.target.value back into state. We .map() over the variants to make one <option> each, and each one needs a key, like our interest cards did.

      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 ana unavailablesold-out variant gives disabled={true}. Same idea with && from Week 5: the "(unavailable)sold out)" text only shows up when the variant isn't available.

      What

      The goes"Selected:" intoline is just there so we can see the state. We'll delete it in Step 3.

      Step 2: Add to cart

      Now we need the cart itself. The cart is a list, so it starts as an empty array. Right under the const [selectedVariantId, setSelectedVariantId] = useState(...) line, add:

        const [cart, setCart] = useState([]);
      

      Now the function that runs when someone clicks Add. Put this right after the closing } of handleVariantChange:

        function handleAdd(variantId) {
          const variant = product.variants.find((option) => option.id === variantId);
      
          setCart([
            ...cart,
            {
              variantId: variant.id,
              name: product.name,
              label: variant.label,
              quantity: 1,
              unitPriceMinor: variant.priceMinor,
            },
          ]);
        }
      

      Now the button. Right under the <p>Selected: {selectedVariantId}</p> line, add:

              <button type="button" onClick={() => handleAdd(selectedVariantId)}>
                Add to cart
              </button>
      

      Last piece: we need to actually show the cart. In the cart section, find:

              <p>Your cart is empty.</p>
      

      Replace it with:

              {cart.length === 0 && <p>Your cart is empty.</p>}
              <div className="interest-list">
                {cart.map((line) => (
                  <article className="interest-card" key={line.variantId}>
                    <h3>{line.name}, {line.label}</h3>
                    <p>{formatPrice(line.unitPriceMinor)} each</p>
                    <p>Quantity: {line.quantity}</p>
                  </article>
                ))}
              </div>
      

      Save. Click Add to cart once. TheYou cartshould now holdssee one object that looks like this:

      {
        variantId: "demo-shirt-small",
        name: "Practice T-shirt",shirt, label:Small, "Small",quantity quantity:1.
      1,

      Now unitPriceMinor:let's 2400,look currency:at "USD",what }we

      just typed, top to bottom.

      variantId.find() saysgoes which option, and quantity says how many. unitPriceMinor is the price of one. The cart is justthrough an array ofand these,gives back the first item that passes your test. Here, it's the variant whose ID matches the one perwe variant.picked.

      Then we build the next cart. ...cart is spread syntax. It copies everything from the old array into a new one, and our new line goes at the end. We make a new array instead of calling cart.push(...), 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.

      In the button, the arrow (() => ...) matters. It's a tiny function that waits for the click. If you wrote onClick={handleAdd(selectedVariantId)}, it would run while React is drawing the page, before anyone clicked. Same mistake as onChange={handleSearch()} in Week 5.

      The cart displaylist usesis the same .map() likeas our interest cardscards. did,The empty message only shows up when there's nothing in the cart, and each line's key is its item.variantId.

      Now click it again.

      Wait, two lines?

      You should see two Small lines, each with quantity 1. Open the browser's Console (right-click, Inspect, Console). React is complaining: "Encountered two children with the same key."

      That's the stablebug we wanted to see. Our handleAdd always adds a new line, even when that size is already in the cart. React is mad because we told it variantId is what makes each line unique, and now two lines have the same one.

      Refresh the page to empty the cart. Now let's fix it.

      In handleAdd, find this line:

          const variant = product.variants.find((option) => option.id === variantId);
      

      Right under it, add:

          if (!variant || !variant.available) {
            return;
          }
      
          const existingLine = cart.find((line) => line.variantId === variantId);
          if (existingLine) {
            setCart(
              cart.map((line) => {
                if (line.variantId === variantId) {
                  return { ...line, quantity: line.quantity + 1 };
                }
                return line;
              })
            );
            return;
          }
      

      Leave the setCart([...cart, { ... }]) that was already there, right below what you just pasted. That's still how a brand-new size gets added. Your whole handleAdd should now look like this (check your braces against it):

        function handleAdd(variantId) {
          const variant = product.variants.find((option) => option.id === variantId);
          if (!variant || !variant.available) {
            return;
          }
      
          const existingLine = cart.find((line) => line.variantId === variantId);
          if (existingLine) {
            setCart(
              cart.map((line) => {
                if (line.variantId === variantId) {
                  return { ...line, quantity: line.quantity + 1 };
                }
                return line;
              })
            );
            return;
          }
      
          setCart([
            ...cart,
            {
              variantId: variant.id,
              name: product.name,
              label: variant.label,
              quantity: 1,
              unitPriceMinor: variant.priceMinor,
            },
          ]);
        }
      

      Read it top to bottom:

        If the variant doesn't exist or isn't available, stop. return ends the function early, so nothing gets added. Look for a cart line that already has this variant ID. If there is one, make a new cart where that line's quantity is 1 higher, and stop there. If there isn't, we fall through to the setCart([...cart, ...]) we already had, which adds a new line.

        .map() visits every line and returns one result for each. For the line we're bumping, { ...line, quantity: line.quantity + 1 } makes a new object with all the old fields copied in, then replaces quantity. Every other line comes back 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 existingLine.quantity = existingLine.quantity + 1. That edits an object React is already holding, which is the same problem as push.

        You might wonder why we check available when the dropdown already greys out Large. 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.)

        Save, then try it:

          Clear the Console (the circle-with-a-line icon at its top left) so the old warning is gone. Add Small twice. You should see one line with quantity 2, and no new warning. Pick Medium and add it. You should see a second, separate line for Medium.

          That's the variant idea working. If Small and Medium ever end up on one line, something is matching on the product ID Reactinstead usesof the variant ID.

          Step 3: Move the button into its own component

          Right now the button lives inside App. On the merch site, one person might build the product controls and someone else the cart, so let's practice splitting the button out. The catch is that the button needs to tellchange linesthe apartcart, whenand onethe getscart removed.lives in App.

          Passing a function to a child

          So far, props have carried text, like a card's title. Props can also carry a function. This isThat's how a child component tells its parent that something happened.

          FindAbove AddButtonexport default function App() { above(outside App:, not inside it), add:

          function AddButton(props) {
            function handleClick() {
              props.onAdd(props.variantId);
            }
          
            return (
              <button type="button" onClick={handleClick}>
                Add to cart
              </button>
            );
          }
          

          AndNow, here'sback wherein App's usesJSX, it:find the debug line and the button from Step 2. That's everything from <p>Selected: down to the button's </button>:

                  <p>Selected: {selectedVariantId}</p>
                  <button type="button" onClick={() => handleAdd(selectedVariantId)}>
                    Add to cart
                  </button>
          

          Delete all of that and replace it with:

                  <AddButton variantId={selectedVariantId} onAdd={handleAdd} />
          

          Save. The "Selected:" line is gone, but Add should work exactly like before. Small twice still gives quantity 2, and Medium still gets its own line.

          Here's what happens when you click:

          App owns selectedVariantId and cart. It passes the selected ID and its handleAdd function to AddButton as props. A browser click calls the child handleClick, which calls props.onAdd(props.variantId). That runs App handleAdd with demo-shirt-small, which finds the variant and calls setCart with a new array. React renders the updated cart. Passing onAdd={handleAdd} does not call it during rendering.

          App hands the button two things: the selected ID, and its own handleAdd function. When someone clicks, the child calls props.onAdd(...), which is really App's handleAdd, and passes it the ID. The button never touches the cart itself.

          Why not just give AddButton its own cart? Because tonslots 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 thismoving state up to that shared parent "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.

          App owns selectedVariantId and cart. It passes variantId set to demo-shirt-small and onAdd set to handleAdd to AddButton as props. A click calls the child's handleClick, which calls props.onAdd(props.variantId). That runs App's handleAdd, which calls addItem and then setCart(updatedCart). Passing onAdd={handleAdd} does not call the function until someone clicks.

          Keep it as onAdd={handleAdd}., Ifwith youno wroteparentheses. onAdd={handleAdd(selectedVariantId)}, it would runcall the functionit right away while React is drawing the page, before anyone clicked. Same mistake as onChange={handleSearch()} in Week 5.page.

          FollowingStep the4: 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.

            RemovingRemove an item

            FindEach cart line needs a Remove button, and the button needs to tell us which line.

            Inside removeItemApp, right above the return ( that starts the page's JSX (the one with <main> under it, not the one in cart.mjsAddButton:), add:

            return  function handleRemove(variantId) {
                setCart(cart.filter((item)line) => item.line.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)line) => item.line.variantId !== variantId is an arrow function, a shorter way to write a function. itemline 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 lookthe atbutton. In the Removecart buttonlist, inright under App.jsx<p>Quantity: {line.quantity}</p>:, add:

                          <button
                            type="button"
                            className="remove-button"
                            onClick={() => handleRemove(item.line.variantId)}
                          >
                            Remove
                          </button>
            

            Each line needspasses toits tellown handleRemovevariantId which ID it is,, so we wrap the call in a tinyan arrow function.again. 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.

            TheSave. 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.

            The cart for this render has two lines: demo-shirt-small with quantity 2 and demo-shirt-medium with quantity 1. changeQuantity runs cart.map. Small matches, so map returns a new object made with ...item and quantity 3. Medium does not match, so map returns the same object. setCart(updatedCart) gives React a new array, and the old array and old Small object stay the way they were.

            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 MediumMedium, once.then Youclick shouldRemove 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. Removeon Small. Medium should still be therethere. withRemove quantityMedium 3.

              If Smalltoo, and Mediumyou evershould endsee up"Your on one line, somethingcart is matchingempty." onagain.

              That's the productcart IDfor insteadtoday. ofYou picked an exact variant, kept the variant ID.

              Add a Clear cart button

              Last thing for the cart. We want a button that empties it. Add this insidein App, underhad handleRemove:

              a
              child component report clicks back up through a function handleClear()prop, {and setCart([]);added setError("");and }removed 
              lines

              Thenwithout putediting the buttonold rightarray. under the cart heading:

              <h2 id="cart-heading">Your cart</h2>
              <button type="button" onClick={handleClear} disabled={cart.length === 0}>
                Clear cart
              </button>
              

              Clearing the cart is just settingCommit it tonow: anin emptyVS 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'Code's nothingSource toControl clear.

              view,

              Save,stage addyour changes and commit with a couplemessage oflike things,Add anda click Clearpractice 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.response, That'sso oneany moretotal reasonyou notadd toup treat our estimate asin the browser is only an estimate. The real total.total comes from checkout.

              A few things change once the cart is on a server:

              1. The click doesn't update the cart right away. It sends a request,request. andIn youthe wait.version we'll build, the page waits for the answer before changing the cart.
              2. The server can say no (out of stock, bad quantity), so you show the error and keep the last cart that actually worked.
              3. You update the page from the cart the server sends back, not from the number you hoped it would be.
              4. 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 importpage failsgoes blank and the Console says useState is not defined KeepAdd the import from Step 1 at the top of cart.mjsApp.jsx, with the other imports (not inside App).
              Clicking does nothing and the Console says products.jscart is not defined inor srcsetCart is not defined The function got pasted outside App (after its last }). Move it inside App, andabove checkreturn (. A red error about an unexpected token or } Usually a paste landed inside the filewrong extensionstag matchor a brace went missing. Compare that spot with the imports.snippet, or grab the catch-up copy for the step you're on. The dropdown changes, but Add uses the wrong size The select should read and set selectedVariantId, and AddButton should get that same ID. Same size twice still makes two lines The existingLine check from Step 2 has to come before the setCart([...cart, ...]) at the bottom of handleAdd, and it needs its return; after the cart.map part. Without that return, both setCart calls run and the second one wins. Small and Medium become one line MatchCheck lines bythe existingLine find in handleAdd. It has to compare line.variantId === variantId, and use item.variantId asnot the key.product An unavailable size gets added Check both disabled on the option and the availability check in addItem.ID. The cartpage changesgoes beforeblank youwith click"Too many re-renders" in the Console Something is calling a function while React draws the page. Pass the function (onAdd={handleAdd}), and wrap Removethe others in an arrow, like () => handleAdd(selectedVariantId). You click Add and the cart stays empty, with no error Check Remove's onClick. Without the arrow, handleRemove(item.line.variantId). runs while the line is being drawn, so it removes itself instantly. Clicking does nothing to the cart Make a new array or object and call setCart. EditingIf you edit the old one with push or item.existingLine.quantity = 3 won't update the page. Changing quantity shows an error Convert the dropdown value with Number(value)2, andReact keepmay itnot fromnotice 1anything to 10. An old error stays after something works Call setError("") after a successful action.changed. The price is 100 times too big The data is in cents. Divide by 100 only once, whenin displaying.formatPrice. The cart is empty after refreshing That's expected. This cart only lives in React state.

              If you fall behind or something won't go away, grab week-07-catch-up.zip. It has a copy of App.jsx for the end of Steps 1, 2 (before and after the fix), and 3. Take the one for the step the group just finished, paste it over your App.jsx, and keep going from there.

              What's next

              Before next week,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 cart-practice 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.both: add Small twice, add Medium, remove Small.

              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.

              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;
              }