@zakkster/lite-resource
v1.0.0
Published
Async state as a signal for @zakkster/lite-signal. resource(source, fetcher) exposes data/error/loading/state as reactive signals with race-safe commits (generation guard), AbortSignal, stale-while-revalidate, and optimistic mutate.
Maintainers
Readme
@zakkster/lite-resource
Async state as a signal for @zakkster/lite-signal. Wrap an async function; get data, error, loading, and a derived state as reactive signals — plus refetch and optimistic mutate. Change the source and it refetches. And the part you can't safely hand-roll: stale responses are dropped, in-flight requests are aborted, and reads are allocation-free.
import { signal, effect } from '@zakkster/lite-signal';
import { resource } from '@zakkster/lite-resource';
const userId = signal(1);
const user = resource(userId, (id, { signal }) =>
fetch(`/api/users/${id}`, { signal }).then(r => r.json())
);
effect(() => {
if (user.loading()) showSpinner();
else if (user.error()) showError(user.error());
else render(user()); // the data
});
userId.set(2); // refetches; if user 1's response is slow, it can't clobber user 2Contents
- Why · What it is / is not · Install · Quick start
- The lifecycle · Race safety · Optimistic mutation
- API reference · Recipes
- Testing (for clients & QA) · Running the demo
- Edge cases & guarantees · Ecosystem · FAQ · License
Why
Every app fetches. The naive version — a loading boolean, a data ref, a try/catch — looks fine until the source changes faster than the network responds. Then request A resolves after B, overwrites it, and your UI shows the wrong record with no error and no clue why. This is the single most common data bug in reactive UIs, and you cannot fix it with a boolean.
A resource fixes it structurally. It tags every fetch with a monotonic generation; when a fetch settles, it commits only if it is still the latest. Slow A resolving after B is silently dropped. The same machinery gives you abort signals, stale-while-revalidate, conditional fetching, and optimistic updates — all exposed as signals your existing computeds and effects already know how to consume.
flowchart LR
S["source signal\n(e.g. userId)"] -->|changes| D["driver effect\n(tracked)"]
D -->|"untracked launch\n++gen, AbortController"| F["fetcher(value, info)"]
F -->|resolve| G{"gen === latest?"}
G -->|yes| C["commit: data, clear error"]
G -->|no| X["drop (stale)"]
F -->|reject| E["error signal"]
C --> ST["state = ready"]
E --> ST2["state = errored"]What it is / is not
- It is a ~150-line async-state primitive: the lite-signal analogue of Solid's
createResource/ the core of SWR. One fetcher, race-safe commits, abort, reactive source, optimistic mutate. - It is not a cache or query layer. There's no shared key cache, no request dedup across resources, no garbage-collection of unused data, no background polling. Those belong in a higher-level library built on top of this (think a future
lite-query). Keeping that out is deliberate — this stays tiny and unopinionated.
Install
npm i @zakkster/lite-resource @zakkster/lite-signal@zakkster/lite-signal is a peer dependency (^1.1.0). ESM-only, ships types. Requires AbortController (all modern browsers; Node 18+) — degrades gracefully to no-abort if absent (the generation guard still prevents stale commits).
Quick start
import { signal } from '@zakkster/lite-signal';
import { resource } from '@zakkster/lite-resource';
// Eager — fetches once on creation:
const config = resource(() => fetch('/config').then(r => r.json()));
// Source-driven — refetches whenever the source changes:
const query = signal('');
const results = resource(query, (q, { signal }) =>
q ? fetch(`/search?q=${encodeURIComponent(q)}`, { signal }).then(r => r.json()) : []
);
await results.refetch(); // force a refresh
results.mutate(prev => [...prev, optimisticRow]); // optimistic updateThe lifecycle
state is a single derived signal you can switch on, so your view logic stays declarative:
| state | meaning | loading | data | error |
|---|---|---|---|---|
| unresolved | no fetch has completed (idle / gated source) | false | undefined | undefined |
| pending | first load, nothing to show yet | true | undefined | — |
| ready | resolved, fresh | false | value | undefined |
| refreshing | re-fetching, previous data still readable | true | last value | — |
| errored | last fetch failed (last good data is retained) | false | last value | the error |
effect(() => {
switch (user.state()) {
case 'pending': return mount(<Spinner/>);
case 'refreshing': return mount(<Stale data={user()} dimmed/>);
case 'ready': return mount(<Profile data={user()}/>);
case 'errored': return mount(<Retry error={user.error()} onRetry={user.refetch}/>);
}
});Race safety
The guarantee, stated plainly: a fetch may only commit if it is the most recent fetch the resource started. Concretely —
const id = signal('A');
const r = resource(id, (key, { signal }) => fetchThing(key, signal));
// fetch A in flight...
id.set('B'); // fetch A aborted, fetch B in flight, generation bumped
// ...B resolves -> data = B
// ...A resolves late -> dropped (its generation is no longer current)This is enforced by a generation counter incremented on every source change, refetch(), mutate(), and dispose(). It's covered by the a stale response … is dropped and superseding a request aborts the previous one tests — the behaviours you'd otherwise discover in production.
Optimistic mutation
mutate() writes data immediately and invalidates any in-flight fetch, so a late server response can't undo your optimistic change. Pair it with a confirming refetch():
async function toggleDone(todo) {
todos.mutate(list => list.map(t => t.id === todo.id ? { ...t, done: !t.done } : t));
try { await api.patch(todo.id, { done: !todo.done }); }
finally { todos.refetch(); } // reconcile with the server (newer generation wins)
}API reference
resource(fetcher, options?) // eager — fetcher receives `true`
resource(source, fetcher, options?) // source-drivensource — an accessor () => S (usually a signal). It is tracked; changes trigger a refetch. A value of null/undefined/false gates the fetch (no request runs; state stays unresolved).
fetcher(value, info) — returns a value or a promise. info is { value, refetching, signal }: the current (possibly stale) data, the refetch() argument (false for source-driven, true by default for manual), and an AbortSignal.
options — initialValue (seed data; first load is refreshing), equals (data-signal equality, default Object.is), lazy (don't fetch until the first refetch()).
Returns a callable resource. r() reads the data (tracked). Properties: data, latest, loading, error, state (read-only signals), peek, subscribe. Methods: refetch(info?) → Promise<void>, mutate(value | updater) → value, dispose().
Recipes
Debounced search — debounce the source, let the resource handle the rest:
const raw = signal('');
const q = signal('');
let t; effect(() => { const v = raw(); clearTimeout(t); t = setTimeout(() => q.set(v), 250); });
const hits = resource(q, (term, { signal }) =>
term ? api.search(term, signal) : []);Dependent resources — a resource's source can read another resource:
const user = resource(userId, fetchUser);
const posts = resource(() => user()?.id, (uid, { signal }) => api.posts(uid, signal));
// ↑ gated until `user` resolves; refetches when the user changesCross-tab + persisted — compose with the rest of the ecosystem: persist the resolved value with @zakkster/lite-persist, and broadcast invalidations with @zakkster/lite-channel so a mutation in one tab refreshes the others.
Testing (for clients & QA)
npm test # node --test test/*.test.js17 deterministic tests, no real network. A scripted fetcher (test/harness.js) hands back a controllable promise per call, so a test decides exactly when each fetch settles — the only way to reproduce a race reliably (start A, start B, resolve B, then resolve A and assert A was dropped).
| Group | What's pinned down |
|---|---|
| Lifecycle | pending→ready, rejection→errored (data retained), sync value, sync throw |
| Source-driven | source change refetches with the new value |
| Race safety | stale response dropped; superseding aborts the previous request |
| refetch | re-runs with current source; keeps stale data; refetching flag |
| mutate | sets data + clears error; invalidates in-flight fetch; updater form |
| Gated source | falsy source skips the fetch; truthy triggers it |
| Options | initialValue seeds + refreshing; lazy; equals suppresses propagation |
| Disposal | aborts in-flight, stops tracking, drops late response; idempotent |
A clean run prints # pass 17 / # fail 0. The visual check is the demo.
Running the demo
example/demo.htmlOpen it directly (no build, no server needed — it uses a simulated API). Type in the search box: each keystroke changes the source and refetches. The request log shows every fetch and marks the stale ones dropped — so you can see the race guard working when an earlier slow request resolves after a later one. Toggle failures to watch errored and retry; hit "mutate" to see an optimistic write survive a late response.
Edge cases & guarantees
- Only the latest commits. Source change,
refetch,mutate, anddisposeall invalidate in-flight requests via the generation counter. - Data is sticky. A failed or in-flight fetch never clears the last good
data;statetells you it's stale (refreshing) or failed (errored). - Fetcher runs untracked. Signal reads inside your fetcher do not become refetch triggers — only the
sourcedoes. - Sync-safe. A fetcher may return a plain value or throw synchronously; both settle on a microtask like a promise.
- Gating. A falsy
sourceis first-class "don't fetch yet", not an error. - Allocation. Reads and idle allocate nothing; each fetch allocates a promise, an
AbortController, and one settle closure — fetches are IO, not a frame loop, so this is honest and fine.
Ecosystem
Part of a zero-GC reactive toolkit; each package is independent and MIT-licensed:
- @zakkster/lite-signal — the reactive core (peer dependency).
- @zakkster/lite-raf — frame scheduler (
rafEffect,frameSchedule()). - @zakkster/lite-channel — cross-tab signal sync; broadcast resource invalidations between tabs.
- @zakkster/lite-persist — persist signals to storage; cache resolved resource data across reloads.
- @zakkster/lite-router — the URL as a signal; a natural
sourcefor route-driven resources. - @zakkster/lite-resource (this package) — async state as a signal.
import { resource } from '@zakkster/lite-resource';
import { route } from '@zakkster/lite-router'; // route params drive the fetch
const page = resource(() => route().params.id, (id, { signal }) => api.page(id, signal));FAQ
How is this different from just signal() + fetch()?
The generation guard, abort, stale-while-revalidate, gating, and optimistic invalidation. The dangerous one is the generation guard — without it, a fast-changing source produces wrong-data-no-error bugs.
Is it a cache like React Query / SWR?
No. It's the async-state layer those are built on. No shared cache, dedup, or background revalidation here — compose those above it (or wait for lite-query).
Can I sync a resource across tabs?
The resolved data is a signal, so yes — sync it with @zakkster/lite-channel, or broadcast a "mutation happened" message and call refetch() in each tab.
Does the fetcher have to use the AbortSignal?
No. If it ignores info.signal, the generation guard still prevents stale commits — you just don't save the wasted bytes.
What counts as "no data"?
undefined. If your API legitimately resolves to undefined, wrap it ({ value }) so state can distinguish "resolved" from "never fetched".
License
MIT © Zahary Shinikchiev
