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つのタブで開いたときと同じです)。それからこのページ全体を再読み込みすると、カートは残っています。
Change the codeコードを変える
Load the storage package:
storageのパッケージを読み込みます。
<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の組を加えます。
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>の後にタグを加えます。
<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の?.のおかげで「…」になります。
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以降)。
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を表示
<!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>