queryResource

The read. Give it a function that returns a request, and it fetches, tracks loading and errors, and refetches when the request changes. Layer on caching, retries, and refresh only where you need them.

@mmstack/resourcenpm

A query is for data you display: a list, a profile, a search result. You describe the request as a function of your signals, and the query keeps the result in sync. Change the id, get the new record. It runs on its own the first time and re-runs whenever the request comes out different, so you never call a fetch method by hand.

With no options it is httpResource with a friendlier return shape. Everything past that (cache, retry, refresh, circuit breaker) is a flag you add per call.

#A first query

Return a string for the common case, or a full request object when you need params, a method, or headers. Because the function reads this.id(), the query refetches every time that signal changes.

import { queryResource } from '@mmstack/resource';

// string shorthand for { url }
readonly user = queryResource<User>(() => `/api/users/${this.id()}`);

// full request; refetches whenever this.query() changes
readonly results = queryResource<Result[]>(() => ({
  url: '/api/search',
  params: { q: this.query() },
}));

The result is a ref of signals. Read them straight in a template and it re-renders as the fetch moves through loading, resolved, and error.

@if (user.isLoading()) {
  <p>Loading…</p>
} @else if (user.error(); as err) {
  <p>Could not load: {{ '{' }}{{ '{' }} err {{ '}' }}{{ '}' }}</p>
} @else {
  <p>{{ '{' }}{{ '{' }} user.value()?.name {{ '}' }}{{ '}' }}</p>
}
<button (click)="user.reload()">Refresh</button>

value() is the current data, status() reports where the fetch is ('loading', 'resolved', 'reloading', 'error', and more), and error() holds the last error. isLoading() and hasValue() are convenience derivations, and reload() forces a refetch past the cache.

#Enabling and disabling

A query often depends on something that isn't ready yet: an id that hasn't loaded, a filter the user hasn't touched. Return undefined/void from the request function and the resource goes quiet. Same behavior as the native resource it's built on. It runs again the moment the function returns a real request. No fetch fires while it's disabled.

readonly post = queryResource<Post>(() => {
  const id = this.selectedId();
  return id ? `/api/posts/${id}` : undefined; // idle until an id is picked
});

To know why a resource is idle, read disabledReason(). It is 'no-request' when the function returned undefined, 'offline' when the network is down, or 'circuit-open' when a circuit breaker tripped, and null when the resource is enabled. Branch your UI on that rather than inspecting the combined status: an offline banner over a held list reads differently from a "service is down" message. We usually expand these with our own options, when appropriate via a computed that listens to it + various other reasons it "could be disabled".

@switch (posts.disabledReason()) {
  @case ('offline') {
    <p>You're offline. Cached posts shown below.</p>
  }
  @case ('circuit-open') {
    <p>The posts service is having trouble. Retrying soon…</p>
  }
  @default {
    <!-- 'no-request' or null: render normally -->
    <post-list [posts]="posts.value()" />
  }
}

#Fire on demand

Sometimes a query shouldn't run when the component mounts. A search should wait for a submit; a "load report" button should wait for the click. That is manualQueryResource. Same request function, same options, same ref of signals, but it stays idle until you call .trigger().

import { manualQueryResource } from '@mmstack/resource';

readonly search = manualQueryResource<SearchResult[]>(() => ({
  url: '/api/search',
  params: { q: this.query() },
}));

onSubmit() {
  this.search.trigger(); // now it fetches, with the current query()
}

.trigger() re-evaluates the request function and fetches with whatever the signals hold at that moment. Everything else (value, status, retry, cache) behaves exactly like queryResource. Reach for it whenever "on construction" is the wrong time to fetch.

#Options

Each option is independent, so a call carries only the ones it uses. Here is a query that keeps its previous value across reloads, polls, refreshes on tab focus, and retries on failure.

readonly posts = queryResource<Post[]>(() => ({ url: '/api/posts' }), {
  defaultValue: [],
  keepPrevious: true,
  refresh: { interval: 60_000, onFocus: true },
  retry: 3,
  onError: (err) => isDevMode() && console.error(err),
});
OptionWhat it does
defaultValue Initial value before the first request resolves. With it set, value() is T rather than T | undefined, so you skip the undefined check in templates.
keepPrevious Hold the previous value, status, and headers while a refresh is in flight, so a reload doesn't flash empty.
refresh A number polls every n ms. The object form adds onFocus (refetch when the tab becomes visible) and onReconnect (when the browser comes back online).
retry Retry N times on failure with exponential backoff (default 1000ms times 2 to the power of the attempt).
cache Opt this resource into the shared cache. See caching.
circuitBreaker Stop hitting a failing endpoint after a threshold. See circuit breakers.
onError Called on every failed attempt with (err, retryCount, isFinal).
register Join the nearest transition scope. See transitions.
pause Join the nearest paused context. pausing.

#onError fires on every attempt

Note: With retry set, onError runs on each failed attempt, not only the last. If you toast the user there, they get one toast per retry. The isFinal flag separates the two concerns: log every attempt, but only tell the user once the retries are spent. If you don't set retry onError fires once, so no worries.

queryResource<Data>(() => ({ url: '/api/data' }), {
  retry: 3,
  onError: (err, retryCount, isFinal) => {
    if (!isFinal) {
      if (isDevMode()) console.warn(`attempt ${retryCount + 1} failed`, err);
      return; // per-attempt telemetry only
    }
    toaster.error('Could not load data.'); // retries spent, tell the user
  },
});

retryCount is how many retries have already happened (0 on the first failure). isFinal is true when no further retry is scheduled, which is also the case when retry is 0.

#Warming the cache

prefetch() fetches into the cache without subscribing, so the data is already there when the user navigates. Wire it to a hover and the click feels instant. It skips itself on slow connections (saveData, 2g), so there is no need to guard it.

<a
  (mouseenter)="posts.prefetch({ url: '/api/posts/' + id() }
  [routerLink]="['/posts', id()]"
>
  {{ '{' }}{{ '{' }} title() {{ '}' }}{{ '}' }}
</a>

#Raw responses

The base call parses JSON, Angular's default. Three sub-functions deliver the body raw instead, mirroring httpResource.text and friends: queryResource.text, queryResource.arrayBuffer, and queryResource.blob. Every option works the same way, and a raw query caches under its own key, so a JSON and a text query for one URL never serve each other's body.

// string body, no JSON parse on the main thread
readonly csv = queryResource.text(() => '/api/report.csv');
csv.value(); // string | undefined

// parse maps the raw body (TRaw is string here)
readonly rows = queryResource.text(() => '/api/report.csv', {
  parse: (s) => s.split('\n'),
});

// binary: transferable to a worker zero-copy
readonly model = queryResource.arrayBuffer(() => '/api/model.bin');
readonly invoice = queryResource.blob(() => '/api/invoice.pdf');

The reason to reach for these is keeping a large parse off the main thread. Parsing a 20MB JSON body blocks the thread that paints. Fetching it as text or a buffer keeps your interceptors and auth intact while the parse moves to a Web Worker that owns the result; an ArrayBuffer even moves zero-copy. If you combine the cache with a transfer, copy the buffer first (buf.slice(0)), since transferring detaches the cached copy. Dev mode warns if a detached buffer is ever served.

#Writing without persisting

value.set() writes through to the cache, so a persisted entry hits IndexedDB and a synced one broadcasts to other tabs. That round-trip is fine for plain data but lossy for anything a structured clone can't carry: a class instance from a custom parse comes back as a bare object on the other side. When your parsed value can't survive that, write it with setLocal() instead. It updates this tab's memory only, skipping both persist and cross-tab sync, and a reload or another tab re-fetches to get its own.

readonly user = queryResource<User>(() => `/api/users/${this.id()}`, {
  parse: (raw) => new User(raw), // a class instance, not a plain object
});

// updates memory only; skips IndexedDB persist and cross-tab broadcast
this.user.setLocal(new User({ ...raw, seen: true }));

#Escape hatches

Two per-call knobs step around the defaults when a request is special.

The dedupe interceptor coalesces identical in-flight requests so three components asking for one URL share a round-trip. To opt a single request out (a probe that must genuinely hit the wire each time), attach noDedupe() to its context.

import { noDedupe } from '@mmstack/resource';

readonly probe = queryResource<Health>(() => ({
  url: '/api/health',
  context: noDedupe(), // this request always reaches the network
}));

A query re-runs when its request function returns something new; an identical request is skipped, which is how a double-click on the same input coalesces into one fetch. Set triggerOnSameRequest: true to fire on every evaluation even when the request object is unchanged. Use it sparingly, since it gives up that built-in coalescing.