← All guides

NgRx SignalStore, measured β€” the rxMethod that never refires, the 500 that kills it, and the effect that fetched 627 times

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

1. Try it in 10 seconds (no signup)

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.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Setup for the snippets: npm i @ngrx/signals (22.x pairs with Angular 22; the store itself needs no provideStore() β€” it is the provider).

2. The happy path: a list store in 25 lines

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.

3. The gotcha: load(category()) never refires

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

4. One uncaught 500 kills the rxMethod β€” permanently and silently

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

5. The race: a naive async method renders stale data; switchMap cancels it

withMethods 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.)

6. The effect that fetched 627 times

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()).

7. withEntities in sixty seconds

import { 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.

8. Loading and error states you can actually see

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.

9. Deterministic retry drills with mock_seq

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

10. Get your own API (10 seconds, no signup)

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.

11. Beyond this guide (two more measured sharp edges)

12. Honest comparison

ToolGood atWhere this differs
angular-in-memory-web-apiThe classic in-process Angular mock backend, zero networkLives 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.
MSWIn-process interception for unit testsMSW 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 utilitiesunprotected() and friends for unit-testing stores directlyThey 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-serverLocal fake REST, huge ecosystemNeeds 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.