Change quantities数量を変える

One line per product, with − and + buttons and a Remove link. On the way you'll meet the one rule of wcstack: assign to the path.

商品ごとに1行、−と+のボタンと削除リンクを付けます。その途中で、wcstackのただ1つの規則に出会います。パスに代入することです。

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

The resultできあがり

Add the same product twice: its line now shows 2. Use − and + to change the quantity, and Remove to take the line out.

同じ商品を2回入れると、その行は2になります。−と+で数量を変え、Removeで行を取り除けます。

Live — this step's pageライブ — このステップのページOpen in a new tab ↗新しいタブで開く ↗

Change the codeコードを変える

Replace addToCart with the version below, and add three handlers for the cart lines. A new line now starts with quantity: 1.

addToCartを下のものに置き換え、カートの行のためのハンドラを3つ加えます。新しい行はquantity: 1で始まります。

index.html — <wcs-state>replaces addToCartaddToCartを置き換え
addToCart(event, index) {
  const product = this.products[index];
  const line = this.cart.findIndex(item => item.id === product.id);
  if (line >= 0) {
    this[`cart.${line}.quantity`] += 1;
  } else {
    this.cart = this.cart.concat({ id: product.id, name: product.name, price: product.price, quantity: 1 });
  }
},
increase(event, index) {
  this[`cart.${index}.quantity`] += 1;
},
decrease(event, index) {
  this[`cart.${index}.quantity`] -= 1;
},
remove(event, index) {
  this.cart = this.cart.toSpliced(index, 1);
}

Each cart line gets a stepper and a Remove button:

カートの各行に、増減ボタンとRemoveボタンを付けます。

index.html — <aside class="cart">replaces the <li><li>を置き換え
<li>
  <span class="line-name">{{ .name }}</span>
  <span class="line-total">${{ .price|toFixed(2) }}</span>
  <span class="stepper">
    <button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
    <span>{{ .quantity }}</span>
    <button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
  </span>
  <button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
</li>

How it worksしくみ

A path can name one cellパスは1つのセルを名指せる

cart.0.quantity is the quantity of the first cart line. The handlers build that path with a template literal — `cart.${index}.quantity` — and assign to it with this[…] += 1. Only the bindings that read that cell update: that line's number and its − button. The other lines are not touched.

cart.0.quantityはカートの1行目の数量です。ハンドラはテンプレートリテラル(`cart.${index}.quantity`)でこのパスを組み立て、this[…] += 1で代入します。更新されるのはそのセルを読むバインディング、つまりその行の数字と−ボタンだけで、ほかの行には触れません。

The rule: assign to the path規則:パスに代入する

this.cart[index].quantity += 1 looks like the same thing, but it is not. this.cart reads the path cart and hands you the plain array; .quantity is then changed directly on the object. No path was assigned, so nothing on the page updates.

this.cart[index].quantity += 1は同じことに見えますが、違います。this.cartはパスcartを読んでふつうの配列を返し、.quantityはそのオブジェクトの上で直接書き換えられます。どのパスにも代入していないので、ページは何も更新されません。

Why it mattersなぜ大事か

The object did change. The screen and the state now disagree, silently, until the next assignment that touches that line shows the hidden change all at once. Making this.cart[index].quantity += 1 work too would look convenient, but then any line of code could change the screen, and you could no longer tell from the code where an update happens. In wcstack, an update is always an assignment to a path — this.x = … or this["a.b"] = … — so you can find every one of them.

オブジェクトは実際に変わっています。画面とstateは黙ってずれたままになり、次にその行に触れる代入が来たとき、隠れていた変化が一度に現れます。this.cart[index].quantity += 1でも動くようにすれば便利に見えますが、そうするとどのコードでも画面を変えられることになり、どこで更新が起きるのかをコードから読み取れなくなります。wcstackでは更新は必ずパスへの代入(this.x = …またはthis["a.b"] = …)なので、すべての更新を見つけられます。

Which index is whichどのインデックスか

The index a handler receives is the index in the loop the click came from. addToCart is clicked in the products loop, so its index is a product index; the cart line is looked up with findIndex and named explicitly, `cart.${line}.quantity`. increase, decrease and remove are clicked in the cart loop, so their index is the cart line.

ハンドラが受け取るindexは、クリックが起きたループでのインデックスです。addToCartは商品のループで押されるので、そのindexは商品のインデックスです。カートの行はfindIndexで探し、`cart.${line}.quantity`と明示して名指します。increase・decrease・removeはカートのループで押されるので、そのindexがカートの行です。

Removing a line行を取り除く

this.cart.toSpliced(index, 1) returns a copy of the cart without that line, and assigning it removes the row. disabled: .quantity|le(1) stops − at 1, so a line never reaches 0; Remove is how it leaves.

this.cart.toSpliced(index, 1)はその行を除いたカートのコピーを返し、それを代入すると行が消えます。disabled: .quantity|le(1)は−を1で止めるので、行の数量が0になることはありません。行を消すのはRemoveです。

Try itやってみよう

In increase, replace the path assignment with this.cart[index].quantity += 1. Reload, add Sencha, and press + twice: the number stays at 1. Now press Sencha's “Add to cart” — a real path assignment — and the line jumps to 4. Put the path assignment back.

increaseのパスへの代入をthis.cart[index].quantity += 1に置き換えてみましょう。再読み込みしてSenchaを入れ、+を2回押しても、数字は1のままです。そこでSenchaの「Add to cart」(本物のパスへの代入)を押すと、行は一気に4になります。確かめたら、パスへの代入に戻してください。

Common mistakesよくある間違い

Changing the object instead of the pathパスではなくオブジェクトを変える

this.cart[index].quantity += 1, this.product.price = 9, Object.assign(this.cart[0], …): all of these change an object you read from the state, none of them assigns to a path. Write this[`cart.${index}.quantity`] += 1, this["product.price"] = 9, or assign a new array or object to the path above.

this.cart[index].quantity += 1、this.product.price = 9、Object.assign(this.cart[0], …)。どれもstateから読んだオブジェクトを変えているだけで、パスには代入していません。this[`cart.${index}.quantity`] += 1やthis["product.price"] = 9と書くか、上の階層のパスに新しい配列やオブジェクトを代入します。

Using the wrong loop's index別のループのインデックスを使う

this[`cart.${index}.quantity`] += 1 inside addToCart would add to the cart line at the product's position — a different line, or none. When a handler in one list changes another list, find the target by something it shares, like id.

addToCartの中でthis[`cart.${index}.quantity`] += 1と書くと、商品の位置にあるカートの行(別の行か、存在しない行)に足してしまいます。あるリストのハンドラが別のリストを変えるときは、idのように両方が持つもので相手を探します。

The whole fileファイル全体

The complete index.html of this step. npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it.

このステップのindex.html全体です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。

Show index.htmlindex.htmlを表示
index.htmlstep 4ステップ4
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Little Roastery</title>
  <link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
  <script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
</head>
<body>

<wcs-state>
  <script type="module">
    export default {
      products: [
        { id: 1, name: "House Blend", description: "Chocolate, caramel, a hint of citrus.", price: 14, stock: 12, icon: "☕" },
        { id: 2, name: "Ethiopia Yirgacheffe", description: "Bright and floral, with jasmine and lemon.", price: 18, stock: 5, icon: "☕" },
        { id: 3, name: "Colombia Supremo", description: "Nutty and smooth, with a cocoa finish.", price: 16, stock: 8, icon: "☕" },
        { id: 4, name: "Sencha", description: "Fresh, grassy Japanese green tea.", price: 12, stock: 20, icon: "🍵" },
        { id: 5, name: "Matcha", description: "Stone-ground, ceremonial grade.", price: 24, stock: 0, icon: "🍵" },
        { id: 6, name: "Measuring Spoon", description: "One scoop, one cup.", price: 6, stock: 30, icon: "🥄" },
        { id: 7, name: "Milk Pitcher", description: "Stainless steel, 350 ml.", price: 15, stock: 4, icon: "🥛" }
      ],
      cart: [],

      addToCart(event, index) {
        const product = this.products[index];
        const line = this.cart.findIndex(item => item.id === product.id);
        if (line >= 0) {
          this[`cart.${line}.quantity`] += 1;
        } else {
          this.cart = this.cart.concat({ id: product.id, name: product.name, price: product.price, quantity: 1 });
        }
      },
      increase(event, index) {
        this[`cart.${index}.quantity`] += 1;
      },
      decrease(event, index) {
        this[`cart.${index}.quantity`] -= 1;
      },
      remove(event, index) {
        this.cart = this.cart.toSpliced(index, 1);
      }
    };
  </script>
</wcs-state>

<header class="shop-header">
  <h1>Little Roastery</h1>
  <p class="cart-badge">Cart: <span data-wcs="textContent: cart.length"></span></p>
</header>

<main class="shop">
  <section class="catalog">
    <h2>Products</h2>
    <div class="grid">
      <template data-wcs="for: products">
        <article class="card" data-wcs="class.sold-out: .stock|eq(0)">
          <div class="card-icon">{{ .icon }}</div>
          <h3>{{ .name }}</h3>
          <p>{{ .description }}</p>
          <p class="price">${{ .price|toFixed(2) }}</p>
          <p class="badge" data-wcs="hidden: .stock|ne(0)">Sold out</p>
          <button type="button" data-wcs="onclick: addToCart; disabled: .stock|eq(0)">Add to cart</button>
        </article>
      </template>
    </div>
  </section>

  <aside class="cart">
    <h2>Cart</h2>
    <ul class="cart-lines">
      <template data-wcs="for: cart">
        <li>
          <span class="line-name">{{ .name }}</span>
          <span class="line-total">${{ .price|toFixed(2) }}</span>
          <span class="stepper">
            <button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
            <span>{{ .quantity }}</span>
            <button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
          </span>
          <button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
        </li>
      </template>
    </ul>
  </aside>
</main>

</body>
</html>