Skip to main content

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

npm install @lit/task

Task status

A Task is always in one of four states, defined by TaskStatus:
StatusValueDescription
TaskStatus.INITIAL0No run has started yet (or the task function returned initialState)
TaskStatus.PENDING1The async function is running
TaskStatus.COMPLETE2The last run resolved successfully
TaskStatus.ERROR3The 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

MemberTypeDescription
task.valueR | undefinedResult of the last successful run
task.errorunknownError from the last failed run
task.statusTaskStatusCurrent status
task.taskCompletePromise<R>Resolves when the current run completes
task.autoRunboolean | 'afterUpdate'Controls automatic execution
task.run(args?)Promise<void>Manually trigger a run
task.abort(reason?)voidAbort the pending run
task.render(renderer)variesRender 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

ValueBehavior
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
falseNever 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,
});