Keep the cart across reloads再読み込みしてもカートを残す

A <wcs-storage> tag keeps the cart in the browser's localStorage: it comes back after a reload, and two tabs of the shop share it. The part to get right is what you bind to it.

<wcs-storage>タグで、カートをブラウザのlocalStorageに残します。再読み込みしても戻ってきて、ショップの2つのタブで共有されます。押さえどころは、そこに何をバインドするかです。

  1. 8
  2. 9
  3. 10
  4. 11
  5. 12

The resultできあがり

Two copies of this step's page, side by side. Add a product on one side, change a quantity on the other: each follows the other, through the localStorage they share — as two tabs of the shop would. Then reload this whole page: the cart is still there.

このステップのページを2枚並べています。片方で商品を入れ、もう片方で数量を変えてみてください。共有するlocalStorageを通して、互いに追従します(ショップを2つのタブで開いたときと同じです)。それからこのページ全体を再読み込みすると、カートは残っています。

Live — this step's pageライブ — このステップのページOpen in a new tab ↗新しいタブで開く ↗
Live — the same page againライブ — 同じページをもう1枚Open in a new tab ↗新しいタブで開く ↗

Change the codeコードを変える

Load the storage package:

storageのパッケージを読み込みます。

index.html — <head>new line1行追加
<script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>

Add a row getter and a getter / setter pair before retry:

retryの前に、行getterを1つと、getterとsetterの組を加えます。

index.html — <wcs-state>new追加
get "cart.*.saved"() {
  return { id: this["cart.*.id"], quantity: this["cart.*.quantity"] };
},
get savedCart() {
  return this.$getAll("cart.*.saved", []);
},
set savedCart(lines) {
  if (Array.isArray(lines)) this.cart = lines;
},

Add the tag after <wcs-fetch>:

<wcs-fetch>の後にタグを加えます。

index.html — <body>new追加
<wcs-storage key="little-roastery-cart"
  data-wcs="value#init=element: savedCart"></wcs-storage>

How it worksしくみ

An input whose value lives in localStorage値がlocalStorageにある入力

<wcs-storage> has one property that works both ways, value, like value: on an <input>. When the tag appears, it reads its key and announces what it found; when value is assigned, it saves it (as JSON). When another tab or frame of the same site saves the key, the new value arrives on its own — that is the browser's storage event, and it is why the two demos above follow each other.

<wcs-storage>には両方向に働くプロパティvalueが1つあり、<input>のvalue:と同じように振る舞います。タグが現れるとkeyを読んで見つけたものを知らせ、valueに代入されると(JSONとして)保存します。同じサイトの別のタブやフレームがそのキーに保存すると、新しい値がひとりでに届きます。ブラウザのstorageイベントで、上の2つのデモが互いに追従するのはそのためです。

Bind what to save, not the cartカートではなく、保存するものをバインドする

A binding is re-applied when its own path is assigned. this.cart = this.cart.concat(…) assigns cart, but this[`cart.${index}.quantity`] += 1 assigns a path under it, and a binding on cart never hears about that — bound to cart, the storage would miss every change of quantity. So the tag is bound to savedCart, a getter that reads each line's id and quantity by path, the way itemCount reads every quantity in step 5. Any change to any line re-evaluates it, the new array goes to value, and the tag saves it. The setter is the way back: a stored cart that arrives — at load, or from another tab — becomes cart.

バインディングは、自分のパスに代入されたときに当て直されます。this.cart = this.cart.concat(…)はcartに代入しますが、this[`cart.${index}.quantity`] += 1はその下のパスに代入し、cartへのバインディングはそれを知りません。cartにバインドしたら、storageは数量の変化をすべて取りこぼします。だからタグはsavedCartにバインドします。各行のIDと数量をパスで読むgetterで、ステップ5のitemCountがすべての数量を読むのと同じやり方です。どの行が変わっても評価し直され、新しい配列がvalueに渡り、タグがそれを保存します。setterは帰り道です。届いた保存済みのカート(読み込み時や、別のタブから)がcartになります。

Who goes first: #init=elementどちらが先か: #init=element

At load two values meet: the cart in localStorage, and the one the state starts with — savedCart of an empty cart, []. By default the state's value is written to the element first, and the empty cart would be saved over the stored one. #init=element makes the element win that first sync: the stored cart goes into the setter. After that, changes flow both ways as usual. On a first visit the key is empty, the element holds null, and the setter ignores anything that isn't an array.

読み込み時には2つの値が出会います。localStorageの中のカートと、stateが最初に持つ値(空のカートのsavedCart、つまり[])です。既定ではstateの値が先に要素へ書かれ、空のカートが保存済みのカートの上に保存されてしまいます。#init=elementは、最初の同期で要素を勝たせます。保存済みのカートがsetterに入ります。その後は、いつもどおり両方向に流れます。初めての訪問ではキーが空で、要素はnullを持ち、setterは配列でないものを無視します。

The stored cart arrives at once; the catalog arrives later. In between, the header's count is already right — it reads only quantities — and the lines' names would be “…”, thanks to the ?. of step 10.

保存済みのカートはすぐに届き、商品一覧は後から届きます。その間もヘッダーの個数はもう正しく(数量しか読まないから)、行の名前はステップ10の?.のおかげで「…」になります。

Try itやってみよう

Keep the chosen category for this tab. Add get savedCategory() { return this.category; } and set savedCategory(value) { if (value) this.category = value; } to the state, and <wcs-storage key="little-roastery-category" type="session" data-wcs="value#init=element: savedCategory"></wcs-storage> to the body. Pick Tea and reload: Tea is still picked. type="session" uses sessionStorage, which each tab keeps for itself.

選んだカテゴリをこのタブの間だけ残してみましょう。stateにget savedCategory() { return this.category; }とset savedCategory(value) { if (value) this.category = value; }を、bodyに<wcs-storage key="little-roastery-category" type="session" data-wcs="value#init=element: savedCategory"></wcs-storage>を加えます。Teaを選んで再読み込みしても、Teaが選ばれたままです。type="session"はsessionStorageを使い、タブごとに別々に保たれます。

Common mistakesよくある間違い

Binding the cart itselfカートそのものをバインドする

With data-wcs="value: cart", add House Blend twice: the page shows 2, and localStorage holds 1, because the second add wrote cart.0.quantity. Reload, and the cart is empty: without #init=element, the state's [] was saved over it. Nothing appears in the console. Step 12 shows the checker catching both.

data-wcs="value: cart"にしてHouse Blendを2回入れると、ページは2を表示し、localStorageには1が入っています。2回目の追加はcart.0.quantityに書いたからです。再読み込みするとカートは空です。#init=elementが無いので、stateの[]がその上に保存されました。コンソールには何も出ません。検査器が両方を見つけるところは、ステップ12で見せます。

Forgetting #init=element#init=elementを忘れる

With value: savedCart, everything works until you reload: then the cart is empty, because savedCart of the starting empty cart was saved over the stored one. The checker reports it (@wcstack/lint 4.0.3 or later):

value: savedCartにすると、再読み込みするまでは問題なく動きます。再読み込みするとカートは空です。最初の空のカートのsavedCartが、保存済みのカートの上に保存されたからです。検査器はこれを報告します(@wcstack/lint 4.0.3以降)。

npx @wcstack/lint index.html
index.html:107:13 warning wcs/storage-seed-clobber The <wcs-storage> value is bound to the accessor pair "savedCart" without #init=element. The state's side goes first, so on every load the getter's starting value is saved over the stored one, and the setter never receives it — add #init=element (`value#init=element: savedCart`)

0 error(s), 1 warning(s), 0 info

The whole fileファイル全体

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

このステップのindex.html全体で、Part 2のショップの完成形です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。

Show index.htmlindex.htmlを表示
index.htmlstep 11ステップ11
<!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>
  <script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
  <script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>
</head>
<body>

<wcs-state>
  <script type="module">
    /** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */

    export default {
      /** @type {Product[] | null} */
      catalog: null,
      loading: false,
      loadError: null,
      reload: false,
      cart: [],
      query: "",
      category: "all",

      get products() {
        return this.catalog ?? [];
      },
      get catalogReady() {
        return this.catalog !== null;
      },
      get "products.*.matches"() {
        const query = this.query.trim().toLowerCase();
        return (this.category === "all" || this["products.*.category"] === this.category) &&
          this["products.*.name"].toLowerCase().includes(query);
      },
      get matchCount() {
        return this.$getAll("products.*.matches", []).filter(Boolean).length;
      },

      get "cart.*.product"() {
        const id = this["cart.*.id"];
        return this.products.find(product => product.id === id);
      },
      get "cart.*.name"() {
        return this["cart.*.product"]?.name ?? "…";
      },
      get "cart.*.price"() {
        return this["cart.*.product"]?.price ?? 0;
      },
      get "cart.*.lineTotal"() {
        return this["cart.*.price"] * this["cart.*.quantity"];
      },
      get itemCount() {
        return this.$getAll("cart.*.quantity", []).reduce((sum, n) => sum + n, 0);
      },
      get subtotal() {
        return this.$getAll("cart.*.lineTotal", []).reduce((sum, n) => sum + n, 0);
      },
      get shipping() {
        return this.subtotal >= 50 ? 0 : 5;
      },
      get total() {
        return this.subtotal + this.shipping;
      },

      get "cart.*.saved"() {
        return { id: this["cart.*.id"], quantity: this["cart.*.quantity"] };
      },
      get savedCart() {
        return this.$getAll("cart.*.saved", []);
      },
      set savedCart(lines) {
        if (Array.isArray(lines)) this.cart = lines;
      },

      retry() {
        this.reload = true;
      },
      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, 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>

<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
  data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>
<wcs-storage key="little-roastery-cart"
  data-wcs="value#init=element: savedCart"></wcs-storage>

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

<p class="status" data-wcs="hidden: loading|not">Loading products…</p>
<p class="status error" data-wcs="hidden: loadError|not">
  Couldn't load the products.
  <button type="button" data-wcs="onclick: retry">Try again</button>
</p>

<main class="shop" data-wcs="hidden: catalogReady|not">
  <section class="catalog">
    <h2>Products</h2>
    <div class="toolbar">
      <input type="search" placeholder="Search products" aria-label="Search products" data-wcs="value: query">
      <fieldset class="categories">
        <legend class="visually-hidden">Category</legend>
        <label><input type="radio" name="category" value="all" data-wcs="radio: category"> All</label>
        <label><input type="radio" name="category" value="coffee" data-wcs="radio: category"> Coffee</label>
        <label><input type="radio" name="category" value="tea" data-wcs="radio: category"> Tea</label>
        <label><input type="radio" name="category" value="tools" data-wcs="radio: category"> Tools</label>
      </fieldset>
    </div>
    <div class="grid">
      <template data-wcs="for: products">
        <article class="card" data-wcs="hidden: .matches|not; 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>
    <p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
  </section>

  <aside class="cart">
    <h2>Cart</h2>
    <template data-wcs="if: cart.length|eq(0)">
      <p class="empty">Your cart is empty.</p>
    </template>
    <template data-wcs="else:">
      <ul class="cart-lines">
        <template data-wcs="for: cart">
          <li>
            <span class="line-name">{{ .name }}</span>
            <span class="line-total">${{ .lineTotal|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>
      <dl class="totals">
        <dt>Subtotal</dt><dd>${{ subtotal|toFixed(2) }}</dd>
        <dt>Shipping</dt><dd>${{ shipping|toFixed(2) }}</dd>
        <dt class="grand">Total</dt><dd class="grand">${{ total|toFixed(2) }}</dd>
      </dl>
      <p class="hint" data-wcs="hidden: shipping|eq(0)">Free shipping on orders of $50 or more.</p>
    </template>
  </aside>
</main>

</body>
</html>