Async Tasks

概要

コンポーネントは非同期でのみ利用可能なデータをレンダリングする必要があることがあるでしょう。 そのようなデータはサーバやデータベースから取得されるでしょう。 より一般的に言うと非同期のAPIを使って取得もしくは計算されるでしょう。

Litのリアクティブアップデートライフサイクルは非同期でプロパティの変更をまとめて処理します。 一方、Litテンプレートは常に同期処理を行います。 テンプレートで使われるデータはレンダリング時に読み込み可能である必要があります。 Litコンポーネントで非同期データをレンダリングするには、データが使用可能になるまで待機して、読み込み可能にするためにそれを保存して、データを同期的に使う新しいレンダリングを発動します。

データをfetchしている間やデータのfetchが失敗したときに何か表示した方が良いでしょう。

@lit/taskパッケージにこの非同期データを扱う処理を扱うためのTaskリアクティブコントローラを用意しています。

Taskはasyncのtask関数を受け取って、手動またはargsが変更されたときに自動的にtask関数を実行するコントローラです。 Tasktask関数の結果を保存します。そして、task関数が完了した際にその結果をレンダリングしてホストコンポーネントを更新します。

下記はfetch()を使ってHTTP APIを利用するTaskの例です。 このAPIはproductId変数が変更される毎に実行されます。 そして、コンポーネントはfetch中にローディングメッセージをレンダリングします。

import {Task} from '@lit/task';

class MyElement extends LitElement {
  @property() productId?: string;

  private _productTask = new Task(this, {
    task: async ([productId], {signal}) => {
      const response = await fetch(`http://example.com/product/${productId}`, {signal});
      if (!response.ok) { throw new Error(response.status); }
      return response.json() as Product;
    },
    args: () => [this.productId]
  });

  render() {
    return this._productTask.render({
      pending: () => html`<p>Loading product...</p>`,
      complete: (product) => html`
          <h1>${product.name}</h1>
          <p>${product.price}</p>
        `,
      error: (e) => html`<p>Error: ${e}</p>`
    });
  }
}

機能

タスク(Task)は非同期での処理を適切に遂行するために以下のような補助をしてくれます。

タスクを使うと非同期データ(Async data)を処理するための同じようなコードを複数回書かなくてすみます。 そして、競合状態や他のエッジケースを確実に制御することができます。

非同期データ(Async data)

非同期データ(Async data)はすぐに利用することはできませんが、将来のある時点では利用できるかもしれないデータです。 For example, instead of a value like a string or an object that's usable synchronously, a promise provides a value in the future.

Async data is usually returned from an async API, which can come in a few forms:

The Task controller deals in promises, so no matter the shape of your async API you can adapt it to promises to use with Task.

タスク(Task)

At the core of the Task controller is the concept of a "task" itself.

A task is an async operation which does some work to produce data and return it in a Promise. A task can be in a few different states (initial, pending, complete, and error) and can take parameters.

A task is a generic concept and could represent any async operation. They apply best when there is a request/response structure, such as a network fetch, database query, or waiting for a single event in response to some action. They're less applicable to spontaneous or streaming operations like an open-ended stream of events, a streaming database response, etc.

Installation

npm install @lit/task

Usage

Task is a reactive controller, so it can respond to and trigger updates to Lit's reactive update lifecycle.

You'll generally have one Task object for each logical task that your component needs to perform. Install tasks as fields on your class:

class MyElement extends LitElement {
  private _myTask = new Task(this, {/*...*/});
}

As a class field, the task status and value are easily available:

this._task.status;
this._task.value;

The task function

The most critical part of a task declaration is the task function. This is the function that does the actual work.

The task function is given in the task option. The Task controller will automatically call the task function with arguments, which are supplied with a separate args callback. Arguments are checked for changes and the task function is only called if the arguments have changed.

The task function takes the task arguments as an array passed as the first parameter, and an options argument as the second parameter:

new Task(this, {
  task: async ([arg1, arg2], {signal}) => {
    // do async work here
  },
  args: () => [this.field1, this.field2]
})

The task function's args array and the args callback should be the same length.

{% aside "positive" "no-header" %}

Write the task and args functions as arrow functions so that the this reference points to the host element.

{% endaside %}

Task status

Tasks can be in one of four states:

The Task status is available at the status field of the Task controller, and is represented by the TaskStatus enum-like object, which has properties INITIAL, PENDING, COMPLETE, and ERROR.

import {TaskStatus} from '@lit/task';

// ...
  if (this.task.status === TaskStatus.ERROR) {
    // ...
  }

Usually a Task will proceed from INITIAL to PENDING to one of COMPLETE or ERROR, and then back to PENDING if the task is re-run. When a task changes status it triggers a host update so the host element can handle the new task status and render if needed.

{% aside "info" "no-header" %}

It's important to understand the status a task can be in, but it's not usually necessary to access it directly.

{% endaside %}

There are a few members on the Task controller that relate to the state of the task:

Rendering Tasks

The simplest and most common API to use to render a task is task.render(), since it chooses the right code to run and provides it the relevant data.

render() takes a config object with an optional callback for each task status:

You can use task.render() inside a Lit render() method to render templates based on the task status:

  render() {
    return html`
      ${this._myTask.render({
        initial: () => html`<p>Waiting to start task</p>`,
        pending: () => html`<p>Running task...</p>`,
        complete: (value) => html`<p>The task completed with: ${value}</p>`,
        error: (error) => html`<p>Oops, something went wrong: ${error}</p>`,
      })}
    `;
  }

Running tasks

By default, Tasks will run any time the arguments change. This is controlled by the autoRun option, which defaults to true.

Auto-run

In auto-run mode, the task will call the args function when the host has updated, compare the args to the previous args, and invoke the task function if they have changed. A task without args defined is in manual mode.

Manual mode

If autoRun is set to false, the task will be in manual mode. In manual mode you can run the task by calling the .run() method, possibly from an event handler:

{% switchable-sample %}

class MyElement extends LitElement {

  private _getDataTask = new Task(
    this,
    {
      task: async () => {
        const response = await fetch(`example.com/data/`);
        return response.json();
      },
      args: () => []
    }
  );

  render() {
    return html`
      <button @click=${this._onClick}>Get Data</button>
    `;
  }

  private _onClick() {
    this._getDataTask.run();
  }
}
class MyElement extends LitElement {

  _getDataTask = new Task(
    this,
    {
      task: async () => {
        const response = await fetch(`example.com/data/`);
        return response.json();
      },
      args: () => []
    }
  );

  render() {
    return html`
      <button @click=${this._onClick}>Get Data</button>
    `;
  }

  _onClick() {
    this._getDataTask.run();
  }
}

{% endswitchable-sample %}

In manual mode you can provide new arguments directly to run():

this._task.run('arg1', 'arg2');

If arguments are not provided to run(), they are gathered from the args callback.

Aborting tasks

A task function can be called while previous task runs are still pending. In these cases the result of the pending task runs will be ignored, and you should try to cancel any outstanding work or network I/O in order to save resources.

You can do with with the AbortSignal that is passed in the signal property of the second argument to the task function. When a pending task run is superseded by a new run, the AbortSignal that was passed to the pending run is aborted to signal the task run to cancel any pending work.

AbortSignal doesn't cancel any work automatically - it is just a signal. To cancel some work you must either do it yourself by checking the signal, or forward the signal to another API that accepts AbortSignals like fetch() or addEventListener().

The easiest way to use the AbortSignal is to forward it to an API that accepts it, like fetch().

{% switchable-sample %}

  private _task = new Task(this, {
    task: async (args, {signal}) => {
      const response = await fetch(someUrl, {signal});
      // ...
    },
  });
  _task = new Task(this, {
    task: async (args, {signal}) => {
      const response = await fetch(someUrl, {signal});
      // ...
    },
  });

{% endswitchable-sample %}

Forwarding the signal to fetch() will cause the browser to cancel the network request if the signal is aborted.

You can also check if a signal has been aborted in your task function. You should check the signal after returning to a task function from an async call. throwIfAborted() is a convenient way to do this:

{% switchable-sample %}

  private _task = new Task(this, {
    task: async ([arg1], {signal}) => {
      const firstResult = await doSomeWork(arg1);
      signal.throwIfAborted();
      const secondResult = await doMoreWork(firstResult);
      signal.throwIfAborted();
      return secondResult;
    },
  });
  _task = new Task(this, {
    task: async ([arg1], {signal}) => {
      const firstResult = await doSomeWork(arg1);
      signal.throwIfAborted();
      const secondResult = await doMoreWork(firstResult);
      signal.throwIfAborted();
      return secondResult;
    },
  });

{% endswitchable-sample %}

Task chaining

Sometimes you want to run one task when another task completes. This can be useful if the tasks have different arguments so that the chained task may run without the first task running again. In this case it'll use the first task like a cache. To do this you can use the value of a task as an argument to another task:

{% switchable-sample %}

class MyElement extends LitElement {
  private _getDataTask = new Task(this, {
    task: ([dataId]) => getData(dataId),
    args: () => [this.dataId],
  });

  private _processDataTask = new Task(this, {
    task: ([data, param]) => processData(data, param),
    args: () => [this._getDataTask.value, this.param],
  });
}
class MyElement extends LitElement {
  _getDataTask = new Task(this, {
    task: ([dataId]) => getData(dataId),
    args: () => [this.dataId],
  });

  _processDataTask = new Task(this, {
    task: ([data, param]) => processData(data, param),
    args: () => [this._getDataTask.value, this.param],
  });
}

{% endswitchable-sample %}

You can also often use one task function and await intermediate results:

{% switchable-sample %}

class MyElement extends LitElement {
  private _getDataTask = new Task(this, {
    task: ([dataId, param]) => {
      const data = await getData(dataId);
      return processData(data, param);
    },
    args: () => [this.dataId, this.param],
  });
}
class MyElement extends LitElement {
  _getDataTask = new Task(this, {
    task: ([dataId, param]) => {
      const data = await getData(dataId);
      return processData(data, param);
    },
    args: () => [this.dataId, this.param],
  });
}

{% endswitchable-sample %}


License

Japanese part

Creative Commons Attribution-NonCommercial 4.0 International Public License

Copyright (c) 2022 38elements

Other

Creative Commons Attribution 3.0 Unported

Copyright (c) 2020 Google LLC. All rights reserved.

BSD 3-Clause License

Copyright (c) 2020 Google LLC. All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

  3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.