Resource

Fine-grained reactive data fetching. Caching, retries, refresh intervals, circuit breakers, request deduplication, and optimistic mutations, all opt-in one feature at a time.

@mmstack/resourcenpm

Fetching data in a component is rarely just the fetch. You end up writing the same supporting cast every time: a loading flag, an error branch, a cache so two components asking for the same thing don't hit the network twice, a retry for the flaky endpoint, a refetch when the user comes back to the tab. @mmstack/resource is that supporting cast, built on Angular's own httpResource and driven by signals.

If you know TanStack Query, this covers the same ground. The difference is the surface: no useQuery hook rules, no observables to subscribe and unsubscribe. A resource is a bundle of signals you read in a template, and it cleans itself up with the component.

npm install @mmstack/resource

The design is opt-in feature by feature. A bare queryResource() with no options behaves exactly like httpResource. Cache, retry, refresh, and circuit breaker are independent knobs you add only where a call needs them, so a simple read stays simple.

#One look at the shape

Here is a read and a write side by side. The read fetches on its own and refetches when its request function returns something new; the write waits for you to call mutate() and updates the list optimistically. Don't worry about every option yet, just notice that both give you back signals you read directly.

import { queryResource, mutationResource } from '@mmstack/resource';
import { untracked } from '@angular/core';

type Post = { id: number; title: string };

// a read: fetches on init, refetches when the URL changes
readonly posts = queryResource<Post[]>(() => '/api/posts', {
  defaultValue: [],
});

// a write: fires on mutate(), updates the list without waiting
readonly addPost = mutationResource(
  (post: Post) => ({ url: '/api/posts', method: 'POST', body: post }),
  {
    onMutate: (post) => this.posts.update((all) => [...all, post]),
  },
);

posts.value(), posts.status(), and posts.error() are signals, so a template that reads them re-renders when the fetch moves. That is the whole read side. queryResource and mutationResource each get their own page below.

#Setup

A plain queryResource() needs nothing, and an in-memory cache is lazily wired up for you, when you first opt-in via a cache option. To turn on caching (dedup, stale-while-revalidate, persistence), register the cache and dedupe interceptors and opt resources in. That, plus provideQueryCache() for a persistent or cross-tab cache, is covered on the caching page.

#Picking a resource

Five flavors cover the shapes fetching tends to take. All are built on httpResource and hand back a ref of signals with at least value(), status(), and error(). The difference is what makes them run.

You want toReach forIt runs on
Read data, cached and refreshablequeryResourceIts request function returning a new value
Read, but only when askedmanualQueryResourceAn explicit .trigger()
Write data, optimisticallymutationResourceAn explicit .mutate(value)
Paginate, accumulating pagesinfiniteQueryResource.fetchNextPage()
Read a live connection (SSE or WebSocket)streamResourceEvery message on the wire

When in doubt, start with queryResource. It is the one you will reach for most, and the others are variations on the same theme.

#Recipes

A few common tasks, each with the real API. Follow the links for the full story on any of them.

Stale-while-revalidate list

Show the cached list instantly, refresh it in the background, and hold the old value so the view never flashes empty on reload. keepPrevious is the flag that keeps value() populated while a refetch is in flight.

readonly posts = queryResource<Post[]>(() => ({ url: '/api/posts' }), {
  defaultValue: [],
  cache: { staleTime: 60_000 }, // fresh for a minute, then revalidate on read
  keepPrevious: true, // hold the last list while the refresh runs
});

Poll while the tab is visible, refresh on return

The object form of refresh combines an interval with event triggers. This polls once a minute and also refetches the moment the user switches back to the tab.

readonly notifications = queryResource<Note[]>(() => ({ url: '/api/notifications' }), {
  refresh: { interval: 60_000, onFocus: true },
});

Optimistic add with rollback

Apply the change to the list before the server confirms, keep the old list as context, and restore it if the request fails. untracked() reads the current value without subscribing to it. Full walkthrough on the mutation page.

readonly addPost = mutationResource(
  (post: Post) => ({ url: '/api/posts', method: 'POST', body: post }),
  {
    onMutate: (post) => {
      const prev = untracked(this.posts.value);
      this.posts.set([...prev, post]);
      return prev; // context for rollback, fully type-safe & inferred downstream
    },
    onError: (_err, prev) => this.posts.set(prev),
  },
);

Per-user cache

Two users share a URL but must not share cached responses. Opt the Authorization header into the cache key with varyHeaders so each user gets their own entry. The header value is one-way digested into the key, never stored raw.

queryResource<Profile>(
  () => ({ url: '/api/me', headers: { ...headers, 'x-identity': auth.id() } }),
  {
    cache: { varyHeaders: ['x-identity'] }, // one entry per user — stable across token refresh
  },
);

Circuit breaker around a flaky endpoint

After a threshold of failures, stop hammering the endpoint and let it recover. Share one breaker across every resource hitting that service so one trip protects all of them. See circuit breakers for the states and recovery.

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

const cb = createCircuitBreaker({ threshold: 5, timeout: 30_000 });

readonly a = queryResource(() => ({ url: '/api/reports' }), { circuitBreaker: cb });
readonly b = queryResource(() => ({ url: '/api/metrics' }), { circuitBreaker: cb });
// five failures on the shared service trips the breaker for both

#Global defaults

When most of your resources want the same options, set them once instead of repeating them per call. Defaults layer in three tiers, and a per-call option always wins over both providers.

provideResourceOptions() is the base layer: it applies to every resource kind (queries, mutations, streams) and covers the common options register, retry, circuitBreaker, and triggerOnSameRequest. provideQueryResourceOptions() and provideMutationResourceOptions() sit above it, scoped to their one kind and inheriting from the base. Precedence runs call site first, then the type-specific provider, then the common provider, so you can make "every query participates in transitions" the default and still turn it off for the odd one with register: false.

import {
  provideResourceOptions,
  provideQueryResourceOptions,
  provideMutationResourceOptions,
} from '@mmstack/resource';

providers: [
  // layer 1: every resource kind
  provideResourceOptions({ retry: { max: 2 }, register: 'indicator' }),
  // layer 2: queries only (inherits + overrides layer 1)
  provideQueryResourceOptions({ circuitBreaker: true }),
  // layer 2: mutations only
  provideMutationResourceOptions({ register: false }),
];

Each provider takes a value or a factory (() => options). Circuit breakers have their own default hook, provideCircuitBreakerDefaultOptions(): every createCircuitBreaker() call without explicit options picks up the threshold and timeout you set there.

#Works with transitions & pausing

Resources plug into the transition scopes from @mmstack/primitives. Set register and a resource joins the nearest <mm-suspense> boundary or transition outlet, so its loading state coordinates with the rest of the subtree instead of flashing a spinner on its own. Pair it with keepPrevious and reloads hold the last view while the fresh one loads. Or use the pause option to hook into an activity boundary. All 3 inter-op gracefully.