Skip to the code
cf.

Cart with a reducer and context

On the tea shop's page, the cart badge lives in the header, the Add buttons live in the product list, and the cart summary lives in a sidebar. They sit in different branches of the tree, but they all show one cart. Hold the cart in a reducer inside a provider, and let each part read it through context.

Export CartProvider by name. It takes children and keeps one cart for everything inside it. The cart starts empty. Its lines are { id, name, price, quantity }, in the order teas were first added.

Export useCart() by name. Inside a CartProvider it returns { lines, count, total, add, remove, clear }: lines is the cart, count is the number of items (the sum of the quantities), total is the sum of price times quantity in dollars, add(tea) adds one of a tea ({ id, name, price }), remove(id) takes out that tea's line, and clear() empties the cart. Outside any provider, useCart() throws an Error with the message useCart must be used inside a CartProvider. Two providers keep two separate carts.

Export three components by name, each reading the cart with useCart() and taking no cart props:

<CartBadge />              // <p>Cart: 3</p>, the count
<ProductList teas={teas} /> // a list with a button "Add Sencha" for each tea
<CartSummary />            // the lines and the total, described below

CartSummary shows a heading Your cart. When the cart is empty it shows Your cart is empty.; otherwise a list with an item per line, each with the text Sencha x 2 and a button Remove Sencha. Under that it always shows Total: $11.00 (two decimals) and an Empty cart button. ProductList uses a few teas of your own when teas isn't given.

The default export, App, puts them together for the Preview: a header with the badge, the product list, and the summary in a sidebar, all inside one CartProvider.

What the tests check

  1. starts with an empty cart
    render(
      <CartProvider>
        <header>
          <CartBadge />
        </header>
        <aside>
          <CartSummary />
        </aside>
      </CartProvider>,
    );
    expect(screen.getByText("Cart: 0")).toBeInTheDocument();
    expect(screen.getByRole("heading", { name: "Your cart" })).toBeInTheDocument();
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    
  2. adding from the product list updates the badge in another branch
    const teas = [{ id: "sencha", name: "Sencha", price: 4 }];
    render(
      <CartProvider>
        <header>
          <nav>
            <CartBadge />
          </nav>
        </header>
        <main>
          <section>
            <ProductList teas={teas} />
          </section>
        </main>
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(screen.getByText("Cart: 1")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(screen.getByText("Cart: 2")).toBeInTheDocument();
    
  3. the summary shows the lines and the total, in step with the badge
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3 },
    ];
    render(
      <CartProvider>
        <CartBadge />
        <ProductList teas={teas} />
        <CartSummary />
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Assam" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    expect(screen.getByText("Cart: 3")).toBeInTheDocument();
    expect(screen.getByText("Sencha x 2")).toBeInTheDocument();
    expect(screen.getByText("Assam x 1")).toBeInTheDocument();
    expect(screen.getByText("Total: $11.00")).toBeInTheDocument();
    expect(screen.queryByText("Your cart is empty.")).not.toBeInTheDocument();
    
  4. removing a line in the summary updates the badge
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3 },
    ];
    render(
      <CartProvider>
        <header>
          <CartBadge />
        </header>
        <main>
          <ProductList teas={teas} />
        </main>
        <aside>
          <CartSummary />
        </aside>
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Sencha" }));
    await user.click(screen.getByRole("button", { name: "Add Assam" }));
    await user.click(screen.getByRole("button", { name: "Remove Sencha" }));
    expect(screen.getByText("Cart: 1")).toBeInTheDocument();
    expect(screen.queryByText("Sencha x 2")).not.toBeInTheDocument();
    expect(screen.getByText("Total: $3.00")).toBeInTheDocument();
    
  5. Empty cart empties the badge and the summary
    const teas = [{ id: "oolong", name: "Oolong", price: 5.5 }];
    render(
      <CartProvider>
        <header>
          <CartBadge />
        </header>
        <main>
          <ProductList teas={teas} />
        </main>
        <aside>
          <CartSummary />
        </aside>
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "Empty cart" }));
    expect(screen.getByText("Cart: 0")).toBeInTheDocument();
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    
  6. useCart returns lines, count, total, add, remove, and clear
    function Probe() {
      const { lines, count, total, add, remove, clear } = useCart();
      return (
        <div>
          <p>{lines.map((line) => line.name + ":" + line.quantity).join(",") || "none"}</p>
          <p>{count} items, {total} dollars</p>
          <button onClick={() => add({ id: "chai", name: "Chai", price: 2.5 })}>Add chai</button>
          <button onClick={() => add({ id: "mint", name: "Mint", price: 1 })}>Add mint</button>
          <button onClick={() => remove("chai")}>Remove chai</button>
          <button onClick={clear}>Clear</button>
        </div>
      );
    }
    render(
      <CartProvider>
        <Probe />
      </CartProvider>,
    );
    await user.click(screen.getByRole("button", { name: "Add chai" }));
    await user.click(screen.getByRole("button", { name: "Add mint" }));
    await user.click(screen.getByRole("button", { name: "Add chai" }));
    expect(screen.getByText("Chai:2,Mint:1")).toBeInTheDocument();
    expect(screen.getByText("3 items, 6 dollars")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Remove chai" }));
    expect(screen.getByText("Mint:1")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Clear" }));
    expect(screen.getByText("none")).toBeInTheDocument();
    expect(screen.getByText("0 items, 0 dollars")).toBeInTheDocument();
    
  7. two providers keep two separate carts
    const teas = [{ id: "sencha", name: "Sencha", price: 4 }];
    render(
      <div>
        <section aria-label="Shop">
          <CartProvider>
            <CartBadge />
            <ProductList teas={teas} />
          </CartProvider>
        </section>
        <section aria-label="Gift shop">
          <CartProvider>
            <CartBadge />
            <ProductList teas={teas} />
          </CartProvider>
        </section>
      </div>,
    );
    const shop = within(screen.getByRole("region", { name: "Shop" }));
    const gifts = within(screen.getByRole("region", { name: "Gift shop" }));
    await user.click(shop.getByRole("button", { name: "Add Sencha" }));
    expect(shop.getByText("Cart: 1")).toBeInTheDocument();
    expect(gifts.getByText("Cart: 0")).toBeInTheDocument();
    await user.click(gifts.getByRole("button", { name: "Add Sencha" }));
    expect(gifts.getByText("Cart: 1")).toBeInTheDocument();
    expect(shop.getByText("Cart: 1")).toBeInTheDocument();
    
  8. useCart outside a CartProvider throws a helpful error
    expect(() => render(<CartBadge />)).toThrow("useCart must be used inside a CartProvider");
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).