Task Controller
The Task controller manages async operations inside components such as data fetching, computations, or any promise-based work. It tracks loading, success, and error states automatically, cancels in-flight requests when args change, and provides a convenient render() helper for mapping states to templates.
import { Task, TaskStatus } from '@webjsdev/core/task';
Basic Usage
Create a Task inside a component. Pass a task function and an args function that returns the reactive inputs. When the args change, the task re-runs automatically.
import { WebComponent, html } from '@webjsdev/core';
import { Task } from '@webjsdev/core/task';
class UserProfile extends WebComponent({ userId: String }) {
constructor() {
super();
this.userId = '';
}
#task = new Task(this, {
task: async ([userId], { signal }) => {
const res = await fetch(`/api/users/${userId}`, { signal });
if (!res.ok) throw new Error(`User not found (${res.status})`);
return res.json();
},
args: () => [this.userId],
});
render() {
return this.#task.render({
pending: () => html`<p>Loading user...</p>`,
complete: (user) => html`
<h2>${user.name}</h2>
<p>${user.email}</p>
`,
error: (err) => html`<p class="error">${err.message}</p>`,
});
}
}
UserProfile.register('user-profile');
Task States
A task is always in one of four states, exposed as TaskStatus constants:
| Status | Value | Meaning |
|---|---|---|
TaskStatus.INITIAL | 0 | Task has never run yet. This is the state before the first args evaluation. |
TaskStatus.PENDING | 1 | Task is currently running. An AbortController is active. |
TaskStatus.COMPLETE | 2 | Task resolved successfully. The result is available at task.value. |
TaskStatus.ERROR | 3 | Task rejected. The error is available at task.error. |
You can check the status directly:
render() {
if (this.#task.status === TaskStatus.PENDING) {
return html`<spinner-icon></spinner-icon>`;
}
if (this.#task.status === TaskStatus.ERROR) {
return html`<p>${this.#task.error.message}</p>`;
}
if (this.#task.status === TaskStatus.COMPLETE) {
return html`<p>${this.#task.value.name}</p>`;
}
return html`<p>Waiting...</p>`;
}
task.render()
The render() helper provides a cleaner pattern. Pass an object with callbacks for each state:
this.#task.render({
initial: () => html`<p>Enter a search term</p>`,
pending: () => html`<p>Searching...</p>`,
complete: (data) => html`<result-list .items=${data}></result-list>`,
error: (err) => html`<p class="error">${err.message}</p>`,
})
All callbacks are optional. If a callback is not provided for the current state, nothing is rendered for that state.
AbortSignal Support
Every task run receives an AbortSignal via the second parameter. The signal is automatically aborted when:
- The task's
argschange, triggering a new run (the previous run is cancelled). - The host component is disconnected from the DOM.
Pass the signal to fetch() or any other AbortSignal-aware API to cancel in-flight work:
#search = new Task(this, {
task: async ([query], { signal }) => {
// If the user types again before this resolves,
// the signal aborts and this fetch is cancelled.
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
return res.json();
},
args: () => [this.query.get()],
});
This prevents race conditions where an older, slower request resolves after a newer one, a common bug in naive async patterns.
autoRun Behavior
By default, the task runs automatically whenever the args function returns new values (compared via shallow equality). This is the autoRun: true behavior.
Set autoRun: false to control when the task runs manually:
#submit = new Task(this, {
task: async ([formData], { signal }) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(formData),
signal,
});
return res.json();
},
args: () => [this.formData.get()],
autoRun: false, // only runs when you call .run()
});
handleSubmit(e) {
e.preventDefault();
this.#submit.run(); // manually trigger the task
}
Use autoRun: false for tasks that should only fire on explicit user action: form submissions, delete confirmations, or manual refresh buttons.
Reactive Args
The args function is called on every host update. When the returned array differs from the previous one (shallow comparison per element), the task re-runs. This creates a reactive chain: property change triggers host update, host update evaluates args, changed args trigger the task.
class SearchResults extends WebComponent({
query: prop(String),
page: prop(Number),
}) {
constructor() {
super();
this.query = '';
this.page = 1;
}
// Re-runs whenever query OR page changes
#results = new Task(this, {
task: async ([q, p], { signal }) => {
const res = await fetch(`/api/search?q=${q}&page=${p}`, { signal });
return res.json();
},
args: () => [this.query, this.page],
});
render() {
return this.#results.render({
pending: () => html`<p>Loading...</p>`,
complete: (data) => html`
<ul>${data.items.map(i => html`<li>${i.title}</li>`)}</ul>
<p>Page ${this.page} of ${data.totalPages}</p>
`,
error: (e) => html`<p>${e.message}</p>`,
});
}
}
SearchResults.register('search-results');
When to Use Task vs Async Page Functions
| Scenario | Use |
|---|---|
| Page-level data loading (blog posts, product details) | Async page function: runs on the server, included in the initial HTML. |
| Search-as-you-type, autocomplete | Task: client-side, reactive to user input, cancels stale requests. |
| Lazy-loaded component data (expand a section, scroll into view) | Task: client-side, runs on demand or on connect. |
| Form submission | Task with autoRun: false: fires on explicit user action. |
| Data shown on first paint (SEO-relevant) | Async page function: server-rendered HTML for crawlers. |
| Data that changes based on client-side state (filters, tabs) | Task: re-runs reactively when args change. |
The rule of thumb: if the data should be in the initial HTML (for SEO, performance, or because it doesn't depend on client state), use an async page function. If the data depends on user interaction or client-only state, use Task.
Full Example: Search With Debounce
import { WebComponent, html, css } from '@webjsdev/core';
import { Task } from '@webjsdev/core/task';
class LiveSearch extends WebComponent {
static styles = css`
:host { display: block; }
input { width: 100%; padding: 8px; font: inherit; }
.results { margin-top: 8px; }
.empty { color: var(--fg-muted, #888); }
`;
query = signal('');
debounced = signal('');
_timer = null;
#results = new Task(this, {
task: async ([q], { signal }) => {
if (!q) return [];
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal });
if (!res.ok) throw new Error('Search failed');
return res.json();
},
args: () => [this.debounced.get()],
});
onInput(e) {
const q = e.target.value;
this.query.set(q);
clearTimeout(this._timer);
this._timer = setTimeout(() => {
this.debounced.set(q);
}, 300);
}
disconnectedCallback() {
clearTimeout(this._timer);
}
render() {
return html`
<input placeholder="Search..."
.value=${this.query.get()}
@input=${(e) => this.onInput(e)} />
<div class="results">
${this.#results.render({
initial: () => html`<p class="empty">Type to search</p>`,
pending: () => html`<p>Searching...</p>`,
complete: (items) => items.length === 0
? html`<p class="empty">No results</p>`
: html`<ul>${items.map(i => html`<li>${i.title}</li>`)}</ul>`,
error: (e) => html`<p class="error">${e.message}</p>`,
})}
</div>
`;
}
}
LiveSearch.register('live-search');
Next Steps
- Reactive Controllers: the general pattern Task is built on
- Context Protocol: share data across components without prop drilling
- Server Actions: server-side data fetching and mutations