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 ignore flag set in a cleanup function, or an AbortController aborted there) are correct, but written by hand, per fetch site.
  • The bookkeeping triplet. data, loading, error plus the transitions between them (does a new attempt clear the old error? does a late rejection from an aborted request set error?): 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 to fetch and 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 in error instead of escaping as an unhandled exception. A new attempt clears error and raises loading immediately. 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 with peek() 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.error inside the fetcher: LJS-206, the async update loop.
  • Scope: fetch lifecycle only. Cache, dedup, retry are app policies; compose with store().