Async data
resource() is the tool for data that arrives later. Give it a fetcher;
get back three plain states and a handle:
const User = component('user', { id: 0 }, (props, { resource }) => {
const user = resource((signal) =>
fetch('/api/users/' + props.id.value, { signal }).then((r) => r.json())
);
return html`<div>
${() => user.loading.value && html`<i>loading…</i>`}
${() => user.error.value && html`<i>failed</i>`}
${() => user.data.value && html`<b>${user.data.value.name}</b>`}
</div>`;
});
The fetcher is tracked (props.id changing re-runs it), and the
engine owns the lifecycle: the previous request is aborted, only the
latest response ever writes, unmount aborts everything.
The problem
Fetching inside a component is three lifecycle problems wearing one
await:
- The out-of-order race. Request A goes out, the input changes, request B goes out, A resolves after B, and the stale response overwrites the fresh one. Nothing throws; the screen is simply wrong. This failure needs a slow network to reproduce, which is why it ships.
- The zombie write. The component unmounts while a request is in
flight; the response lands and writes into a dead instance. The standard
guards (an
ignoreflag set in a cleanup function, or anAbortControlleraborted there) are correct, but written by hand, per fetch site. - The bookkeeping triplet.
data,loading,errorplus the transitions between them (does a new attempt clear the old error? does a late rejection from an aborted request seterror?): a state machine every component reinvents.
Hand-rolled guards have a specific property: they are absent by
default. The natural code, fetch().then(set), is the racy code,
and the guard is a discipline the author must remember each time.
How LemonadeJS solves it
resource() moves the guards from convention into the engine, so the
natural code is the guarded code:
- Tracked re-fetch. The fetcher runs through dependency tracking like a template expression: any state it reads (props, filters, ids) re-runs it on change.
- Abort-previous. Each re-run aborts the previous request’s
AbortSignal. Pass it tofetchand the network request itself is cancelled, not just ignored. - Latest-wins. Each run is numbered; a response from a superseded run writes nothing. The out-of-order race is not a bug you avoid; it is a write that cannot happen.
- Unmount aborts. Disposal aborts the in-flight request and retires every pending write. The zombie write is structurally gone.
- Quiet edges. A rejection from a superseded or aborted request is
silent: it never sets
error. A synchronous throw in the fetcher lands inerrorinstead of escaping as an unhandled exception. A new attempt clearserrorand raisesloadingimmediately. All of these transitions are pinned in the engine suite (tests/resource.test.ts). reload()+peek()for imperative timing.reload()re-runs the fetcher without re-tracking. When you decide the timing (a debounce timer, a refresh button), read inputs withpeek()inside the fetcher so no tracked dependency competes with your schedule.
One loop remains possible, so it has a name: a fetcher that reads the
resource’s own data/loading/error re-triggers itself when the
response lands. That is an async cycle the synchronous LJS-203 loop guard
cannot see, because the cycle crosses an await. Dev builds warn
LJS-206. Derive from resource.data elsewhere, in slots or
computed(), never inside the fetcher.
Scope is held deliberately. resource() is fetch lifecycle, full
stop: no cache, no dedup, no retry, no invalidation. Those are
application policies with application-specific answers, and they compose
on top: a module-scope store() as a cache, your retry
policy in the fetcher.
In practice
The timeline deleted its alive flag. The v5 timeline guarded its
remote fetch with a manual liveness flag; the v6 port runs on
resource() and the flag is gone, and with it a latent bug: the v5
code checked alive but had no answer to out-of-order responses, a
race that was simply never written down. The shipped source documents
the trade:
// v5 fetchRemote on the resource() tool: ... The fetcher PEEKS everything
// (refresh() decides WHEN via reload()) and the engine owns the
// lifecycle: a new request aborts the stale one, only the latest
// response lands, unmount aborts (v5's alive flag and its out-of-order
// race are gone).
const remote = resource((signal) => { ... });
The dropdown’s remote search. Autocomplete against url?q= is the
canonical race: type “ab”, then “abc”. Two requests are in flight, and the
shorter query often resolves last. The dropdown’s debounce timer commits
the query and calls reload(); the fetcher peeks. A newer search aborts
the stale request and only the latest response lands, where the old
then-chain implementation had exactly the window the engine now closes.
// the imperative-timing pattern (debounced remote search)
const results = resource((signal) =>
fetch(url.peek() + '?q=' + term.peek(), { signal }).then((r) => r.json())
);
const onType = (q) => {
clearTimeout(timer);
timer = setTimeout(() => { term.value = q; results.reload(); }, 300);
};
The engine suite is the contract. Out-of-order responses resolved in
reverse order leave the latest data in place; unmount mid-flight aborts
the signal and a late response writes nothing, not even loading moves
again; an aborted request’s rejection never reaches error. Each is a
test, not a promise.
Reference
const r = resource(fetcher); // component tool
// fetcher: (signal: AbortSignal) => T | Promise<T>
// TRACKED: state reads re-run it (use peek() to read without tracking)
// pass signal to fetch() so aborts cancel the network request
r.data // State<T | undefined> last successful value
r.loading // State<boolean> true while a run is in flight
r.error // State<unknown|undefined> last failure; cleared on new attempt
r.reload() // re-run imperatively (no re-tracking)
- Previous run aborts on re-run; only the latest run’s response writes.
- Unmount aborts in-flight work; nothing writes after disposal.
- Superseded/aborted rejections are silent; synchronous throws →
error. - Never read
r.data/r.loading/r.errorinside the fetcher:LJS-206, the async update loop. - Scope: fetch lifecycle only. Cache, dedup, retry are app policies; compose with store().