Async Tasks
Overview
Permalink to “Overview”Sometimes a component needs to render data that is only available asynchronously. Such data might be fetched from a server, a database, or in general retrieved or computed from an async API.
While Lit's reactive update lifecycle is batched and asynchronous, Lit templates always render synchronously. The data used in a template must be readable at the time of rendering. To render async data in a Lit component, you must wait for the data to be ready, store it so that it's readable, then trigger a new render which can use the data synchronously. Considerations must often be made on what to render while the data is being fetched, or when the data fetch fails as well.
The @lit/task
package provides a Task
reactive controller to help manage this async data workflow.
Task
is a controller that takes an async task function and runs it either manually or automatically when its arguments change. Task stores the result of the task function and updates the host element when the task function completes so the result can be used in rendering.
Example
Permalink to “Example”This is an example of using Task
to call an HTTP API via fetch()
. The API is called whenever the productId
parameter changes, and the component renders a loading message when the data is being fetched.
Features
Permalink to “Features”Task takes care of a number of things needed to properly manage async work:
- Gathers task arguments when the host updates
- Runs task functions when arguments change
- Tracks the task status (initial, pending, complete, or error)
- Saves the last completion value or error of the task function
- Triggers a host update when the task changes status
- Handles race conditions, ensuring that only the latest task invocation completes the task
- Renders the correct template for the current task status
- Allows aborting tasks with an
AbortController
This removes most of the boilerplate for correctly using async data from your code, and ensures robust handling of race conditions and other edge-cases.
What is async data?
Permalink to “What is async data?”Async data is data that's not available immediately, but may be available at some time in the future. 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:
- Promises or async functions, like
fetch()
- Functions that accept callbacks
- Objects that emit events, such as DOM events
- Libraries like observables and signals
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.
What is a task?
Permalink to “What is a 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
Permalink to “Installation”
Usage
Permalink to “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:
As a class field, the task status and value are easily available:
The task function
Permalink to “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:
The task function's args array and the args callback should be the same length.
Write the task
and args
functions as arrow functions so that the this
reference points to the host element.
Task status
Permalink to “Task status”Tasks can be in one of four states:
INITIAL
: The task has not been runPENDING
: The task is running and awaiting a new valueCOMPLETE
: The task completed successfullyERROR
: The task errored
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
.
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.
It's important to understand the status a task can be in, but it's not usually necessary to access it directly.
There are a few members on the Task controller that relate to the state of the task:
status
: the status of the task.value
: the current value of the task, if it has completed.error
: the current error of the task, if it has errored.render()
: a method that chooses a callback to run based on the current status.
Rendering Tasks
Permalink to “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:
initial()
pending()
complete(value)
error(err)
You can use task.render()
inside a Lit render()
method to render templates based on the task status:
Running tasks
Permalink to “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
Permalink to “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
Permalink to “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:
In manual mode you can provide new arguments directly to run()
:
If arguments are not provided to run()
, they are gathered from the args
callback.
Aborting tasks
Permalink to “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 AbortSignal
s like fetch()
or addEventListener()
.
The easiest way to use the AbortSignal
is to forward it to an API that accepts it, like fetch()
.
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:
Task chaining
Permalink to “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:
You can also often use one task function and await intermediate results:
More accurate argument types in TypeScript
Permalink to “More accurate argument types in TypeScript”Task argument types can sometimes be inferred too loosely by TypeScript. This can be fixed by casting argument arrays with as const
. Consider the following task, with two arguments.
As written, the type of the argument list to the task function is inferred as Array<number | string>
.
But ideally this would be typed as a tuple [number, string]
because the size and position of the args is fixed.
The return value of args
can be written as args: () => [this.myNumber, this.myText] as const
, which will result in a tuple type for the args list to the task
function.