npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@zakkster/lite-resource

npm version npm bundle size npm downloads npm total downloads TypeScript lite-signal peer License: MIT

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 2

Contents


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 update

The 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-driven

source — 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.

optionsinitialValue (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 changes

Cross-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.js

17 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.html

Open 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, and dispose all invalidate in-flight requests via the generation counter.
  • Data is sticky. A failed or in-flight fetch never clears the last good data; state tells you it's stale (refreshing) or failed (errored).
  • Fetcher runs untracked. Signal reads inside your fetcher do not become refetch triggers — only the source does.
  • Sync-safe. A fetcher may return a plain value or throw synchronously; both settle on a microtask like a promise.
  • Gating. A falsy source is 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:

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