rxMethod that never refires, the 500 that kills it, and the effect that fetched 627 timessignalStore is the modern NgRx: no actions, no reducers, no selectors β state as signals, methods as functions, and rxMethod to bridge RxJS for data fetching. It is genuinely the nicest store Angular has ever had. It also has failure modes that produce zero console output: an rxMethod that silently ignores every subsequent click, a stream one uncaught error kills for the life of the store, and an effect() that will happily DDoS your own API.
Everything below was reproduced and measured in a real Chrome on Angular 22.1.7 with @ngrx/signals 22.0.1 and an instrumented window.fetch before publishing. The numbers β a list frozen on 9 beauty products under a header that says toys, 104 fetches by the 5-second mark, exactly 1 aborted request, zero fetches after a single 500 β are observed counts, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:4200, StackBlitz, CI, anywhere β no proxy.conf.json. When you want your own schema it's one curl or one click β see Β§10.
npm i @ngrx/signals (22.x pairs with Angular 22; the store itself needs no provideStore() β it is the provider).import { signalStore, withState, withMethods, patchState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, of, catchError } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';
const API = 'https://mockbird.mockbird.workers.dev/m/demo';
export const ProductsStore = signalStore(
withState({ items: [] as Product[], loading: false, error: '' }),
withMethods((store) => ({
load: rxMethod<string>(pipe(
tap(() => patchState(store, { loading: true, error: '' })),
switchMap((cat) => fromFetch(`${API}/products?category=${cat}`).pipe(
switchMap((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }),
catchError((e) => of({ __err: e.message })),
)),
tap((d: any) => d?.__err
? patchState(store, { error: d.__err, loading: false })
: patchState(store, { items: d, loading: false })),
)),
})),
);
In the component, provide it and β this is the part that matters β call the rxMethod with the signal itself, not its value:
@Component({ providers: [ProductsStore], /* ... */ })
export class ProductList {
store = inject(ProductsStore);
category = signal('beauty');
constructor() {
this.store.load(this.category); // β the SIGNAL, no parentheses
}
}
Measured: exactly 1 fetch on mount (category=beauty, 9 items). Click a button that does category.set('toys') β the rxMethod refires by itself: second fetch, list swaps to 6 toys. No effect, no subscription management.
load(category()) never refiresOne pair of parentheses changes everything. rxMethod accepts a static value, a signal, or an observable. Pass this.category() β the value β and you've handed it a string. It runs once and never hears about the signal again:
constructor() {
this.store.load(this.category()); // β the VALUE. Runs once. Forever.
}
Measured: 1 fetch on mount. Then we clicked the toys button twice: zero new fetches. The header bound to category() dutifully updated to say toys while the list underneath was still 9 beauty products. Zero console warnings, zero errors β the two states just quietly disagree, which is exactly the kind of bug that survives code review. If a filter change should refetch, the rxMethod must receive the signal (or an observable); if you only ever want an imperative one-shot, calling it with a value is fine β just know which one you wrote.
rxMethod β permanently and silentlyThis is the sharpest edge in the room. An rxMethod is one long-lived subscription. If an error escapes to the top-level pipe, RxJS does what RxJS always does: it terminates the stream. Every later call to the method is delivered to a dead subscription.
// β οΈ no catchError anywhere:
load: rxMethod<number>(pipe(
switchMap((status) => fromFetch(`${API}/products?limit=3${status ? '&mock_status=' + status : ''}`).pipe(
switchMap((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }),
)),
tap((d: any) => patchState(store, { items: d })),
)),
Measured: first click loads 3 items. Then we made the endpoint fail once with ?mock_status=500 β console shows ERROR Error: HTTP 500 β and clicked the healthy button twice more: zero fetches. The store looks normal, the method is callable, nothing warns. It's just dead until the store is destroyed.
The fix is where you catch, not whether: catchError must sit inside the switchMap's inner pipe, so the error dies before it reaches the outer stream:
switchMap((status) => fromFetch(url).pipe(
switchMap((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }),
catchError((e) => { patchState(store, { error: e.message }); return of(null); }), // β inner
)),
Re-measured: same 500 β error lands in state (HTTP 500 rendered), and the next click fetches again and recovers. This is also why tapResponse from @ngrx/operators exists β it's this pattern packaged, so you can't put the catch in the wrong place. Drill both branches against the demo: ?mock_status=500 on demand, remove it, click again.
switchMap cancels itwithMethods lets you write a plain async method β and it will betray you under latency:
async loadNaive(url: string) {
const r = await fetch(url);
patchState(store, { items: await r.json() }); // last RESPONSE wins, not last CLICK
}
We pinned the first request slow with ?category=beauty&mock_delay=1500, then clicked toys (no delay) 150 ms later. Measured: the UI showed beauty β the stale slow response arrived last and clobbered the toys data the user actually asked for. Zero aborts, zero warnings.
The rxMethod version from Β§2 already fixes this: switchMap unsubscribes the in-flight inner observable, and because fromFetch wires unsubscription to AbortController, the stale request is actually cancelled at the network layer. Re-measured, same click pattern: UI shows toys, and the instrumentation counted exactly 1 aborted request β the slow beauty one. (Angular's HttpClient inside rxMethod behaves the same way; plain fetch in an async method never will.)
Skip the store methods, "just sync it with an effect()" β and read the state your own callback writes:
effect(() => {
const n = this.store.items().length; // read
fetch(`${API}/products?limit=5`)
.then((r) => r.json())
.then((d) => patchState(this.store, { items: d })); // write β new array β rerun
});
Every patchState installs a fresh array, the effect's dependency "changed", it reruns, fetches, patchesβ¦ Measured in one coherent 20-second run: 35 fetches by 2 seconds, 104 fetches by the 5-second mark, 627 requests total by ~20 seconds β and accelerating whenever the tab gets rendering frames (a separate run passed 1,300). The console shows nothing. The UI shows a perfectly healthy "items: 5" the entire time. The only place this is visible is the network tab β or your API's request log; Mockbird's per-project inspector is how we counted before the instrumentation confirmed it.
The rule is the same one rxMethod enforces structurally: effects that fetch must not read the state their callback writes. Put loads in rxMethod (or at least guard the effect's reads with untracked()).
withEntities in sixty secondsimport { withEntities, setAllEntities } from '@ngrx/signals/entities';
const ProductsStore = signalStore(
{ protectedState: false },
withEntities<Product>(),
);
// after fetching:
patchState(store, setAllEntities(products));
// store.ids() store.entities() store.entityMap() are now signals
Measured against the demo's 30 products: 1 fetch, ids() length 30, entities() length 30, 0 duplicate ids β records keyed by id out of the box (use selectId in the config for anything else). setAllEntities, addEntity, updateEntity, removeEntity replace the reducer boilerplate the old NgRx needed an adapter for.
The demo simulates whatever your skeleton and error UI need, on demand:
store.load(0); // ?mock_delay=2000 β two full seconds of loading:true
store.load(500); // ?mock_status=500 β your error branch, on demand
Measured: with ?mock_delay=2000 the loading() signal read true at the 300 ms probe and flipped to false with 5 items after the delay; with ?mock_status=500 the store's error() rendered HTTP 500 and loading() settled false. Every skeleton, spinner, and error toast becomes a URL parameter instead of a DevTools throttling session.
mock_seqRetry code is the least-tested code in most apps because you can't make a real API fail twice then succeed. mock_seq makes failure sequences deterministic:
load: rxMethod<string>(pipe(
switchMap((key) => fromFetch(
`${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=${key}`).pipe(
switchMap((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }),
retry({ count: 2, delay: 250 }), // inner pipe β resubscribes the fetch
)),
tap((d: any) => patchState(store, { items: d, done: true })),
)),
// call with a fresh key per drill: store.load('k' + Date.now())
Measured: exactly 3 requests β 503, 503, then 200 β and the list rendered after the third. Note retry lives on the inner pipe for the same reason as catchError in Β§4: it must resubscribe the fetch, not the whole method. The sequence is tracked per mock_seq_key (and per client), so parallel test workers don't steal each other's failures.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments β or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Ship day is one environment token change.
protectedState is a compile-time promise, not a runtime one. By default external patchState is a TypeScript error β but we cast around it and the "protected" store patched fine at runtime, silently (@ngrx/signals 22.0.1). It's a lint fence, not a lock; don't build invariants on it.rxMethod needs an injection context. Create one in a click handler and you get, verbatim: NG0203: rxMethod() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Define methods in withMethods / field initializers and this never bites.POST/PATCH/DELETE actually stick β build optimistic-update methods and verify against reality.GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod) β a typed withEntities<Product> for free.POST /m/<project>/auth/login returns a real signed JWT β test interceptors and 401 flows (guide).httpResource has its own measured sharp edges: that guide is here. Service-class HttpClient version: Mock API for Angular.| Tool | Good at | Where this differs |
|---|---|---|
| angular-in-memory-web-api | The classic in-process Angular mock backend, zero network | Lives inside your build β curl can't hit it, CI E2E and teammates can't share it, and Β§5's abort behavior and Β§6's request counts are invisible to it. Mockbird is a real URL over a real network. |
| MSW | In-process interception for unit tests | MSW mocks live inside each test runner. A hosted mock is the same URL for your browser, CI, a StackBlitz repro, and a teammate's machine. Use MSW for unit tests, a hosted mock for everything shared. |
| @ngrx/signals testing utilities | unprotected() and friends for unit-testing stores directly | They test the store's logic with mocked data sources. This guide's bugs (dead streams, races, storms) live in the fetch layer β you need a real network to see them at all. |
| json-server | Local fake REST, huge ecosystem | Needs Node running everywhere the API is needed. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted β plus auth simulation, failure injection (mock_seq, mock_status, mock_delay), snapshots. |
Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements β rerun them in your own Chrome with the snippets as written.