Overview
Fetching remote data in a component means handling at least three states: an initial state before any fetch, a pending state while the fetch runs, and either a complete state with data or an error state if the request fails. Coordinating these states manually requires boilerplate that’s easy to get wrong — especially when requests race.
@lit/task provides a Task reactive controller that encapsulates this pattern. It runs an async function whenever its arguments change, tracks the current status, requests host updates at the right moments, and cancels stale requests automatically via AbortSignal.
Installation
Task status
A Task is always in one of four states, defined by TaskStatus:
| Status | Value | Description |
|---|
TaskStatus.INITIAL | 0 | No run has started yet (or the task function returned initialState) |
TaskStatus.PENDING | 1 | The async function is running |
TaskStatus.COMPLETE | 2 | The last run resolved successfully |
TaskStatus.ERROR | 3 | The last run rejected |
Constructor
The recommended form uses a config object:
new Task(host: ReactiveControllerHost, config: {
task: (args: T, options: { signal: AbortSignal }) => Promise<R>;
args?: () => T; // called each update to get current args
autoRun?: boolean | 'afterUpdate'; // default: true
argsEqual?: (oldArgs: T, newArgs: T) => boolean;
initialValue?: R; // skip INITIAL; start in COMPLETE
onComplete?: (value: R) => unknown;
onError?: (error: unknown) => unknown;
})
A shorter two-argument form is also supported:
new Task(host, taskFn, argsFn)
Properties and methods
| Member | Type | Description |
|---|
task.value | R | undefined | Result of the last successful run |
task.error | unknown | Error from the last failed run |
task.status | TaskStatus | Current status |
task.taskComplete | Promise<R> | Resolves when the current run completes |
task.autoRun | boolean | 'afterUpdate' | Controls automatic execution |
task.run(args?) | Promise<void> | Manually trigger a run |
task.abort(reason?) | void | Abort the pending run |
task.render(renderer) | varies | Render output based on current status |
task.render()
render() selects the appropriate template for the current status:
task.render({
initial?: () => unknown;
pending?: () => unknown;
complete?: (value: R) => unknown;
error?: (error: unknown) => unknown;
})
Any key can be omitted; if the current status has no matching renderer, render() returns undefined.
autoRun option
| Value | Behavior |
|---|
true (default) | Checks args during willUpdate and runs if they changed |
'afterUpdate' | Checks and runs after updated() — can see rendered DOM, but causes a second update |
false | Never runs automatically; call task.run() explicitly |
autoRun: 'afterUpdate' is unlikely to be SSR-compatible in the future. Avoid it in SSR contexts.
Complete example
This example fetches a user profile from an API whenever the userId property changes, and renders the appropriate state at each stage.
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {Task} from '@lit/task';
interface User {
id: number;
name: string;
email: string;
}
@customElement('user-profile')
class UserProfile extends LitElement {
@property({type: Number}) userId = 1;
private _userTask = new Task(this, {
task: async ([userId], {signal}) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{signal}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<User>;
},
args: () => [this.userId] as const,
});
render() {
return html`
<article>
${this._userTask.render({
initial: () => html`<p>Enter a user ID to load a profile.</p>`,
pending: () => html`<p>Loading user ${this.userId}…</p>`,
complete: (user) => html`
<h2>${user.name}</h2>
<p>${user.email}</p>
`,
error: (err) => html`
<p>Failed to load user: ${err instanceof Error ? err.message : 'Unknown error'}</p>
`,
})}
</article>
`;
}
}
Manual triggering
Set autoRun: false to run the task only in response to an event:
@customElement('search-box')
class SearchBox extends LitElement {
@property() query = '';
private _searchTask = new Task(this, {
task: async ([q], {signal}) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {signal});
return res.json();
},
args: () => [this.query] as const,
autoRun: false,
});
render() {
return html`
<input
.value=${this.query}
@input=${(e: InputEvent) => (this.query = (e.target as HTMLInputElement).value)}
/>
<button @click=${() => this._searchTask.run()}>Search</button>
${this._searchTask.render({
pending: () => html`<p>Searching…</p>`,
complete: (results) => html`<pre>${JSON.stringify(results, null, 2)}</pre>`,
error: (err) => html`<p>Error: ${err}</p>`,
})}
`;
}
}
Awaiting task completion
Use taskComplete when you need to wait for a run to finish before proceeding, for example in tests:
await element._userTask.taskComplete;
Argument equality
By default Task uses shallowArrayEquals, which compares each argument with !==. If your arguments are objects that change identity but not content, supply a custom argsEqual function. @lit/task also exports deepArrayEquals for deep structural comparison:
import {Task, deepArrayEquals} from '@lit/task';
const task = new Task(this, {
task: async ([filters]) => fetchData(filters),
args: () => [this.filters] as const,
argsEqual: deepArrayEquals,
});
Returning initialState
The task function can return the special initialState symbol to reset the task back to TaskStatus.INITIAL. This is useful when arguments indicate that no fetch should happen:
import {Task, initialState} from '@lit/task';
const task = new Task(this, {
task: async ([id]) => {
if (!id) return initialState; // reset, show nothing
return fetchItem(id);
},
args: () => [this.selectedId] as const,
});