Async State#
Model async work as a Task. Loading, error, stale, and success stop being ad-hoc booleans and become first-class reactive values. match() routes each state to the DOM update that belongs to it.
Model Async Work as a Task#
When a component needs to load data — fetch a fragment, import a module, run any async work — the naive approach is a loading flag, an error variable, and a watch that does the fetching. Three pieces of state, none of them reactive, all of them easy to get wrong.
Model the work itself as a Task instead. A Task is an async derivation. It auto-cancels in-flight work when its dependencies change. It exposes four states through match(): ok, nil, stale, and err.
Routing precedence is nil > err > stale > ok:
nilfires on the first run, before any value has resolvederrfires when the task rejectsstalefires when the task has a retained value and is recomputing after a dependency change — use it to keep the old content visible while refreshingokfires with the resolved value
The module-lazyload component shows the full pattern. It fetches an HTML fragment and injects it into a content element. It drives separate loading, error, and content views from a single Task:
defineComponent('module-lazyload', ({ expose, first, host, watch }) => {
const callout = first('card-callout', 'Needed to display loading state and error messages.')
const loading = first('.loading', 'Needed to display loading state.')
const errorEl = first('.error', 'Needed to display error messages.')
const contentEl = first('.content', 'Needed to display content.')
const content = createTask(async (_prev, abort) => {
const url = host.src
if (!url) throw new Error('No URL provided')
const response = await fetch(url, { signal: abort })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.text()
})
expose({ src: asString() })
watch(content, {
ok: html => {
loading.hidden = true
contentEl.hidden = false
contentEl.innerHTML = html
},
nil: () => {
loading.hidden = false
contentEl.hidden = true
},
stale: () => {
contentEl.style.setProperty('opacity', 'var(--opacity-dimmed)')
return () => contentEl.style.removeProperty('opacity') // reset on next dispatch
},
err: error => {
loading.hidden = true
errorEl.hidden = false
errorEl.textContent = error.message
contentEl.hidden = true
return () => { errorEl.hidden = true; errorEl.textContent = '' }
},
})
})
The HTML provides all three regions up front. The watch handler toggles their visibility as the Task moves through its states:
<module-lazyload src="./fragments/details.html">
<card-callout>
<p class="loading" role="status">Loading…</p>
<p class="error" role="alert" aria-live="assertive" hidden></p>
</card-callout>
<div class="content" hidden></div>
</module-lazyload>
Return a cleanup from stale and err handlers
stale and err receive no arguments. They may return a cleanup function that runs synchronously before the next dispatch. Use it to reset DOM state you changed, such as removing a dimming class or clearing the error text. This way the next ok or stale run starts clean.
The Task owns the async work
Do not fetch inside a plain watch callback. A Task receives an AbortSignal. It auto-cancels when its dependencies change, so switching src aborts the in-flight request. Its pending and error states become first-class reactive values that compose through match().
The playground below is live. The first fetch starts on its own — watch nil light up while nothing has resolved, then give way to ok. Refetch and the old release stays visible under stale until the new one lands. Tick the failure box and the next refetch ends in err. Every log line is a real state transition of one Task.
Fetch Data into a List#
A Task fetches one value. When the data is a keyed list, derive a DerivedList from the fetch and hand it to reconcile(). deriveList accepts the async function directly. It manages cancellation and refresh internally:
defineComponent('module-users', ({ expose, first, host, watch }) => {
const container = first('[data-container]', 'Add a container element for users.')
const template = first('template', 'Add a template element for users.')
expose({ src: asString() })
const users = deriveList(
async (_prev, abort) => {
const response = await fetch(host.src, { signal: abort })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
},
{ initial: [], keyConfig: user => user.id },
)
reconcile(container, template, users, (element, item) => { /* fill content */ })
watch(() => isPending(users), pending => { container.ariaBusy = String(pending) })
})
{ initial: [] } seeds the list before the first response. { keyConfig } gives each fetched item a stable key. When src changes, the in-flight request aborts and reconcile() re-syncs the container to the new keys. isPending() works on any signal with an async origin, the derived list included.
To adapt a signal you already hold — a Task created elsewhere — pass a thunk that reads it: deriveList(() => task.get(), { keyConfig: item => item.id }).