Skip to the code
cf.

Cart reducer

The tea shop's cart can add a tea, remove a line, change a line's quantity, and empty itself. Written as separate event handlers, that logic gets scattered. Gather it into one reducer function and run the cart with useReducer.

The cart is an array of lines, { id, name, price, quantity }, in the order they were first added. It starts empty. Export cartReducer(cart, action) by name. It returns the next cart for these actions, which are plain objects:

{ type: "added", tea: { id, name, price } }   // a new line with quantity 1, or one more of a line that's there
{ type: "removed", id }                         // drop that line
{ type: "quantityChanged", id, quantity }       // set that line's quantity; 0 or less drops the line
{ type: "cleared" }                             // an empty cart

The reducer is pure: it never changes the cart or the lines it's given, and returns new arrays and objects instead. Any other type throws an Error whose message is Unknown action: followed by the type, like Unknown action: bought.

The default export, App, is the cart screen. It takes teas, a list of { id, name, price } with prices in dollars; when it isn't given, as in the Preview, show a few teas of your own. For each tea there's a button Add Sencha (with the tea's name).

Under the heading Your cart, show Your cart is empty. while it's empty. Otherwise show a list with one item per line. Each item has the text Sencha x 2 (name, x, quantity) and three buttons: - labelled One less Sencha, + labelled One more Sencha, and Remove Sencha. Going below 1 with - takes the line out. Below the cart, show Total: $11.00, the sum of price times quantity with two decimals, and an Empty cart button.

<li>
  Sencha x 2
  <button aria-label="One less Sencha">-</button>
  <button aria-label="One more Sencha">+</button>
  <button>Remove Sencha</button>
</li>

What the tests check

  1. adding a new tea appends a line with quantity 1
    const cart = cartReducer([], { type: "added", tea: { id: "sencha", name: "Sencha", price: 4 } });
    expect(cart).toEqual([{ id: "sencha", name: "Sencha", price: 4, quantity: 1 }]);
    const next = cartReducer(cart, { type: "added", tea: { id: "assam", name: "Assam", price: 3 } });
    expect(next).toEqual([
      { id: "sencha", name: "Sencha", price: 4, quantity: 1 },
      { id: "assam", name: "Assam", price: 3, quantity: 1 },
    ]);
    
  2. adding a tea that's already there adds one more to its line
    const sencha = { id: "sencha", name: "Sencha", price: 4 };
    let cart = cartReducer([], { type: "added", tea: sencha });
    cart = cartReducer(cart, { type: "added", tea: { id: "assam", name: "Assam", price: 3 } });
    cart = cartReducer(cart, { type: "added", tea: sencha });
    expect(cart).toEqual([
      { id: "sencha", name: "Sencha", price: 4, quantity: 2 },
      { id: "assam", name: "Assam", price: 3, quantity: 1 },
    ]);
    
  3. removed, quantityChanged, and cleared do what they say
    const cart = [
      { id: "sencha", name: "Sencha", price: 4, quantity: 2 },
      { id: "assam", name: "Assam", price: 3, quantity: 1 },
    ];
    expect(cartReducer(cart, { type: "removed", id: "sencha" })).toEqual([{ id: "assam", name: "Assam", price: 3, quantity: 1 }]);
    expect(cartReducer(cart, { type: "quantityChanged", id: "assam", quantity: 5 })).toEqual([
      { id: "sencha", name: "Sencha", price: 4, quantity: 2 },
      { id: "assam", name: "Assam", price: 3, quantity: 5 },
    ]);
    expect(cartReducer(cart, { type: "cleared" })).toEqual([]);
    
  4. a quantity of 0 or less takes the line out
    const cart = [
      { id: "sencha", name: "Sencha", price: 4, quantity: 2 },
      { id: "assam", name: "Assam", price: 3, quantity: 1 },
    ];
    expect(cartReducer(cart, { type: "quantityChanged", id: "sencha", quantity: 0 })).toEqual([{ id: "assam", name: "Assam", price: 3, quantity: 1 }]);
    expect(cartReducer(cart, { type: "quantityChanged", id: "assam", quantity: -1 })).toEqual([{ id: "sencha", name: "Sencha", price: 4, quantity: 2 }]);
    
  5. never changes the cart it's given
    const line = Object.freeze({ id: "sencha", name: "Sencha", price: 4, quantity: 1 });
    const cart = Object.freeze([line]);
    const actions = [
      { type: "added", tea: { id: "sencha", name: "Sencha", price: 4 } },
      { type: "added", tea: { id: "assam", name: "Assam", price: 3 } },
      { type: "quantityChanged", id: "sencha", quantity: 3 },
      { type: "removed", id: "sencha" },
      { type: "cleared" },
    ];
    for (const action of actions) {
      const next = cartReducer(cart, action);
      expect(next).not.toBe(cart);
    }
    expect(cart).toEqual([{ id: "sencha", name: "Sencha", price: 4, quantity: 1 }]);
    expect(cartReducer(cart, actions[0])[0]).not.toBe(line);
    
  6. throws on an unknown action
    expect(() => cartReducer([], { type: "bought" })).toThrow("Unknown action: bought");
    
  7. starts empty, and Add buttons fill the cart and the total
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3 },
    ];
    render(<App teas={teas} />);
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    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.queryByText("Your cart is empty.")).not.toBeInTheDocument();
    expect(screen.getByText("Sencha x 2")).toBeInTheDocument();
    expect(screen.getByText("Assam x 1")).toBeInTheDocument();
    expect(screen.getByText("Total: $11.00")).toBeInTheDocument();
    
  8. the + and - buttons change a line, and - at 1 takes it out
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "oolong", name: "Oolong", price: 5.5 },
    ];
    render(<App teas={teas} />);
    await user.click(screen.getByRole("button", { name: "Add Oolong" }));
    await user.click(screen.getByRole("button", { name: "One more Oolong" }));
    await user.click(screen.getByRole("button", { name: "One more Oolong" }));
    expect(screen.getByText("Oolong x 3")).toBeInTheDocument();
    expect(screen.getByText("Total: $16.50")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "One less Oolong" }));
    expect(screen.getByText("Oolong x 2")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "One less Oolong" }));
    await user.click(screen.getByRole("button", { name: "One less Oolong" }));
    expect(screen.queryByText(/Oolong x/)).not.toBeInTheDocument();
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    
  9. Remove takes out one line and Empty cart takes out all
    const teas = [
      { id: "sencha", name: "Sencha", price: 4 },
      { id: "assam", name: "Assam", price: 3 },
    ];
    render(<App teas={teas} />);
    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.queryByText("Sencha x 1")).not.toBeInTheDocument();
    expect(screen.getByText("Assam x 1")).toBeInTheDocument();
    expect(screen.getByText("Total: $3.00")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Empty cart" }));
    expect(screen.getByText("Your cart is empty.")).toBeInTheDocument();
    expect(screen.getByText("Total: $0.00")).toBeInTheDocument();
    

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