When loading fails読み込みに失敗したら

A file can be missing and a network can drop. Bind the error of <wcs-fetch> to say so, and its trigger to try again — without a try / catch in sight.

ファイルが無いこともあれば、回線が切れることもあります。<wcs-fetch>のerrorをバインドしてそれを伝え、triggerをバインドしてもう一度試します。try / catchはどこにも出てきません。

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

The resultできあがり

This copy of the page asks for a file that doesn't exist, so you can see the failure. Press “Try again”: the request runs again — “Loading products…” flashes — and fails again. The page with the right URL ↗ looks like step 8.

このページのコピーは存在しないファイルを読みに行くので、失敗の様子が見えます。「Try again」を押すと、リクエストがもう一度走り(「Loading products…」が一瞬出ます)、また失敗します。正しいURLのページ ↗はステップ8と同じに見えます。

Live — this step's page, with a missing fileライブ — このステップのページ(ファイルが無い場合)Open in a new tab ↗新しいタブで開く ↗

Change the codeコードを変える

Add two keys after loading:

loadingの後ろにキーを2つ加えます。

index.html — <wcs-state>new追加
catalog: null,
loading: false,
loadError: null,
reload: false,

Add a method before addToCart:

addToCartの前にメソッドを1つ加えます。

index.html — <wcs-state>new追加
retry() {
  this.reload = true;
},
addToCart(event, index) {

Bind two more properties of <wcs-fetch>, and put an error line after the loading line:

<wcs-fetch>のプロパティをさらに2つバインドし、読み込み中の行の後にエラーの行を置きます。

index.html — <body>changed変更
<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
  data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>
<!-- … -->
<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>

How it worksしくみ

Three outputs, three keys3つの出力と3つのキー

A request is in one of three conditions — on its way, failed, arrived — and <wcs-fetch> reports each as a property: loading, error and value. Each lands in its own key, and each line of the page reads one of them. When the file is missing, error becomes an error object (an HTTP error carries the status code) and value goes back to null, so catalog is null, the shop stays hidden and products is still an empty list. hidden: loadError|not shows the line while there is an error. When a later request succeeds, error is null again and the line goes away by itself.

リクエストは3つの状態(届く途中・失敗・届いた)のどれかにあり、<wcs-fetch>はそれぞれをプロパティで知らせます。loading・error・valueです。それぞれが自分のキーに入り、ページの各行はそのどれかを読みます。ファイルが無いと、errorはエラーのオブジェクト(HTTPのエラーならステータスコードを持つ)になり、valueはnullに戻ります。だからcatalogはnullで、ショップは隠れたまま、productsは空のリストのままです。hidden: loadError|notは、エラーがある間その行を表示します。後のリクエストが成功するとerrorはnullに戻り、行はひとりでに消えます。

A command written as a value値として書いた命令

trigger is an input that means “run now”: writing true to it starts the request again. So retry only assigns this.reload = true, and trigger: reload carries it to the element. When the request is done, the element sets trigger back to false and announces it, which brings reload back to false, ready for the next press. Your code never holds the element or calls a method on it; it changes state, as everywhere else.

triggerは「今実行して」を意味する入力で、trueを書き込むとリクエストがもう一度始まります。だからretryはthis.reload = trueと代入するだけで、trigger: reloadがそれを要素へ運びます。リクエストが終わると、要素はtriggerをfalseに戻してそれを知らせ、reloadもfalseに戻って、次に押されるのを待ちます。コードは要素をつかむことも、そのメソッドを呼ぶこともありません。ほかの場所と同じく、stateを変えるだけです。

Try itやってみよう

Show the HTTP status in the message. Add loadStatus: 0, to the state, status: loadStatus to the bindings of <wcs-fetch>, and (<span data-wcs="textContent: loadStatus"></span>) after “Couldn't load the products.”. Change the URL to …/no-such-file.json: the message ends with (404).

メッセージにHTTPのステータスを出してみましょう。stateにloadStatus: 0,を、<wcs-fetch>のバインディングにstatus: loadStatusを、「Couldn't load the products.」の後に(<span data-wcs="textContent: loadStatus"></span>)を加えます。URLを…/no-such-file.jsonに変えると、メッセージの最後に(404)が付きます。

Common mistakesよくある間違い

Binding only valuevalueだけをバインドする

That was the page of step 8. When the request fails, value goes back to null and the shop simply never appears — no message, nothing to press, and only the console knows why. Whenever you bind value, bind error too.

それがステップ8のページでした。リクエストが失敗するとvalueはnullに戻り、ショップは現れないままです。メッセージも押すものも無く、理由を知っているのはコンソールだけです。valueをバインドするときは、errorも必ずバインドします。

Starting the trigger at truetriggerをtrueで始める

reload: true fires as soon as the binding is made, so the catalog is requested twice at startup. A trigger has no memory of what it was; any true it is given runs the request. The checker knows:

reload: trueはバインドした瞬間に発火するので、起動時に商品一覧を2回取りに行きます。triggerは前の値を覚えておらず、渡されたtrueはどれもリクエストを実行します。検査器はこれを知っています。

npx @wcstack/lint index.html
index.html:84:65 warning wcs/trigger-seeded-truthy The trigger-bound slot "reload" is seeded with true. trigger has no edge detection (any truthy write fires, and it bypasses manual), so it fires immediately at bind. Seed it with false

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

The whole fileファイル全体

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

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

Show index.htmlindex.htmlを表示
index.htmlstep 9ステップ9
<!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>
</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.*.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;
      },

      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, 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>

<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
  data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>

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