Advanced Signal Store: withEntities, rxMethod, signalMethod, withLinkedState and Custom Store Features
The advanced parts of Signal Store take repeated work out of your stores: withEntities manages collections with updaters, rxMethod handles cancellable async work with RxJS, signalMethod does the same without RxJS, and custom features plug into any store. We move Bookstore's cart to entities, build quick search with rxMethod, add withRequestStatus() and a typed log feature using SignalStoreFeatureType, and show with real output how an error without tapResponse ends the stream.
Version note: The examples in this post were run with Angular 22.2, NgRx 22.0.1 (@ngrx/signals, @ngrx/operators), TypeScript 6.0 and Node.js 24 (Angular 22.2 projects use TypeScript 6.0); browser behaviour was observed with Playwright 1.65 in headless Chromium against the mock Bookstore API in the snapshot.
Predict First
In bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, we are moving the cart lines into an NgRx entity collection. A line is identified by its ISBN. A teammate simplifies the add method like this; addEntity is the updater from @ngrx/signals/entities that adds an item to a collection:
add(book: Book): void {
patchState(store, addEntity({ isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }, lineConfig));
},
In the catalog, "Add to cart" is clicked twice for Dune and once for Emma. What does the counter in the header show: 3 or 2? What is the quantity on the Dune line on the cart page, and do you see an error in the browser console? Write down your prediction; the answer is in the "withEntities" section.
In the previous post we turned the hand-written cart and the checkout store into NgRx Signal Stores. In this post we add the advanced parts of Signal Store to Bookstore: withEntities for the cart lines, rxMethod for a quick search box in the header, signalMethod for a "recently viewed books" list, and two custom features that any store can use: withRequestStatus() and withRequestLog(), which takes the first one as its input. Finally we write a reusable keyboard selection feature with withFeature and withLinkedState.
withEntities: Cart Lines in a Collection
The cart as it stands at the end of this post:
import { HttpClient } from '@angular/common/http';
import { computed, effect, inject } from '@angular/core';
import { patchState, signalStore, type, withComputed, withHooks, withMethods, withProps, withState } from '@ngrx/signals';
import {
addEntity,
entityConfig,
removeAllEntities,
removeEntity,
setAllEntities,
updateEntity,
withEntities,
} from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';
import { apiUrl } from '../api';
import type { Book, CartItem, Coupon } from '../models';
import { withRequestLog } from '../shared/with-request-log';
import { setError, setFulfilled, setPending, withRequestStatus } from '../shared/with-request-status';
// Cart lines as an entity collection: the ISBN is the id, '_' keeps the collection private
const lineConfig = entityConfig({
entity: type<CartItem>(),
collection: '_line',
selectId: (line) => line.isbn,
});
export const CartStore = signalStore(
{ providedIn: 'root' },
withEntities(lineConfig),
withState({ coupon: null as Coupon | null }),
withRequestStatus(),
withRequestLog('cart'),
withProps(() => ({
_http: inject(HttpClient),
_storageKey: 'bookstore.cart',
})),
withComputed(({ _lineEntities }) => ({
// the public API stays the same: components still read cart.items()
items: _lineEntities,
count: computed(() => _lineEntities().reduce((sum, line) => sum + line.quantity, 0)),
totalPrice: computed(() => _lineEntities().reduce((sum, line) => sum + line.price * line.quantity, 0)),
})),
withComputed(({ totalPrice, coupon }) => ({
discount: computed(() => {
const c = coupon();
return c ? Math.round(totalPrice() * c.percent) / 100 : 0;
}),
})),
withComputed(({ totalPrice, discount }) => ({
payable: computed(() => totalPrice() - discount()),
})),
withMethods((store) => ({
add(book: Book): void {
// addEntity ignores an id that already exists, so an existing line is updated instead
patchState(
store,
store._lineEntityMap()[book.isbn]
? updateEntity({ id: book.isbn, changes: (line) => ({ quantity: line.quantity + 1 }) }, lineConfig)
: addEntity({ isbn: book.isbn, title: book.title, price: book.price, quantity: 1 }, lineConfig),
);
},
setQuantity(isbn: string, quantity: number): void {
patchState(
store,
quantity < 1 ? removeEntity(isbn, lineConfig) : updateEntity({ id: isbn, changes: { quantity } }, lineConfig),
);
},
remove(isbn: string): void {
patchState(store, removeEntity(isbn, lineConfig));
},
clear(): void {
patchState(store, removeAllEntities(lineConfig), { coupon: null });
},
async applyCoupon(code: string): Promise<void> {
patchState(store, setPending());
try {
const url = `${apiUrl}/coupons/${encodeURIComponent(code)}`;
const coupon = await firstValueFrom(store._http.get<Coupon>(url));
patchState(store, { coupon }, setFulfilled());
} catch (error) {
patchState(store, { coupon: null }, setError(error));
}
},
})),
withHooks({
onInit(store) {
const saved = readSavedCart(store._storageKey);
patchState(store, setAllEntities(saved.items, lineConfig), { coupon: saved.coupon });
// Save a versioned copy of the cart, not getState(): entityMap, ids and requestStatus are internal details
effect(() => {
const cart: SavedCart = { version: 2, items: store.items(), coupon: store.coupon() };
localStorage.setItem(store._storageKey, JSON.stringify(cart));
});
},
}),
);
type SavedCart = { version: 2; items: CartItem[]; coupon: Coupon | null };
// Reads every shape this app has ever saved: v0 array (posts 01-04), v1 object (05), v2 (06)
function readSavedCart(key: string): SavedCart {
const empty: SavedCart = { version: 2, items: [], coupon: null };
try {
const saved: unknown = JSON.parse(localStorage.getItem(key) ?? 'null');
if (Array.isArray(saved)) return { ...empty, items: saved as CartItem[] };
if (saved && typeof saved === 'object' && Array.isArray((saved as SavedCart).items)) {
const { items, coupon } = saved as SavedCart;
return { version: 2, items, coupon: coupon ?? null };
}
return empty;
} catch {
return empty; // storage blocked or corrupt JSON: start with an empty cart
}
}
withEntities adds two fields to the state: ids (the order of the identifiers) and entityMap (a record from identifier to entity). entities is a computed derived from these two. By default an entity is expected to have a property called id; our lines are identified by isbn, so we gave entityConfig a selectId. collection: '_line' names the collection: the fields become _lineIds, _lineEntityMap and _lineEntities, and the _ prefix hides them from outside the store, like the private members of the previous post. We expose a read-only view with items: _lineEntities, so the header, cart and checkout components still read cart.items() and none of them changed.
Updates are made with entity updaters: addEntity, updateEntity, removeEntity, setAllEntities, removeAllEntities. They are ordinary updaters passed to patchState, and they combine with another partial state in the same call (like { coupon: null } in clear()). One detail: selectId has to be passed in the config to the add*, set* and update* updaters, while the remove* updaters take the identifier directly.
The same clicks with the simplified add from "Predict First":
header : Cart (2)
cart lines : Dune x 1 = 450 TRY | Emma x 1 = 320 TRY
localStorage: {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":1},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":null}
The counter is 2, Dune's quantity is 1, and there is no error in the console. The NgRx documentation says so explicitly: if an entity with the same identifier already exists, addEntity does not override it and does not throw either. That is the right behaviour for a collection (adding the same record twice is usually a bug), but for a cart it means "one more". So the add in the real code uses updateEntity with an updater function (quantity + 1) when the line exists, and addEntity when it does not:
header : Cart (3)
cart lines : Dune x 2 = 900 TRY | Emma x 1 = 320 TRY
localStorage: {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2},{"isbn":"9780141439587","title":"Emma","price":320,"quantity":1}],"coupon":null}
There is also upsertEntity, which merges into an existing entity, but it writes the fields of the incoming object over the old ones; to increase a quantity you need to read the current value, and that is the updater form of updateEntity.
A Named Collection and signalMethod: Recently Viewed Books
We want to show the last three books whose details the user opened in the catalog. That is a small piece of client state living in root:
import { patchState, signalMethod, signalStore, type, withMethods } from '@ngrx/signals';
import { entityConfig, prependEntity, removeEntities, removeEntity, withEntities } from '@ngrx/signals/entities';
import type { Book } from '../models';
// Named collection: viewedEntities, viewedIds and viewedEntityMap instead of entities, ids and entityMap
const viewedConfig = entityConfig({
entity: type<Book>(),
collection: 'viewed',
selectId: (book) => book.isbn,
});
export const RecentlyViewedStore = signalStore(
{ providedIn: 'root' },
withEntities(viewedConfig),
withMethods((store) => ({
// Takes a value, a signal or a computation; a synchronous update needs no RxJS
track: signalMethod<Book | undefined>((book) => {
if (!book) return;
patchState(store, removeEntity(book.isbn, viewedConfig), prependEntity(book, viewedConfig));
patchState(store, removeEntities(store.viewedIds().slice(3), viewedConfig));
}),
})),
);
This time the collection name is public: viewedEntities, viewedIds, viewedEntityMap. You can use more than one withEntities with different names in the same store; even so, the NgRx documentation recommends a dedicated store per entity type in most cases. prependEntity puts the book at the start of the list; it does not add an existing one, so we remove it with removeEntity first and then prepend it. Anything beyond three is dropped with removeEntities.
track is a signalMethod. It accepts a value, a signal or a computation function; given a signal, it runs every time the value changes. The book details page calls it in its constructor:
// signalMethod called with a computation, in the component's injection context: cleaned up with the page
inject(RecentlyViewedStore).track(() => (this.book.hasValue() ? this.book.value() : undefined));
There is an effect inside, with two differences: only the given computation is tracked (the method body runs untracked, so reading store.viewedIds() in the body does not create a new dependency), and the same method can be called several times with different inputs. The store lives in root, but because the call is made in the component's injection context, the inner effect is cleaned up when the page is destroyed. The NgRx documentation says that calling it with a signal outside an injection context and without an injector is deprecated and will throw in a future version; if you call a root store's method with a signal from ngOnInit, pass { injector }, otherwise the effect outlives the component.
rxMethod: Quick Search in the Header
The search box in the header shows suggestions as you type. This is where RxJS really helps: waiting for a pause in typing, not sending the same query twice, and cancelling the request that is no longer needed.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { tapResponse } from '@ngrx/operators';
import { patchState, signalStore, withFeature, withHooks, withMethods, withProps, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { debounceTime, distinctUntilChanged, EMPTY, map, pipe, switchMap } from 'rxjs';
import { apiUrl } from '../api';
import type { Book, BookPage } from '../models';
import { withKeyboardSelection } from '../shared/with-keyboard-selection';
import { withRequestLog } from '../shared/with-request-log';
import { setError, setFulfilled, setPending, withRequestStatus } from '../shared/with-request-status';
// Local UI state with server calls: provided by the search box component, one instance per box
export const SearchStore = signalStore(
withState({ query: '', results: [] as Book[] }),
withRequestStatus(),
withRequestLog('search'),
withFeature(({ results }) => withKeyboardSelection(results)),
withProps(() => ({ _http: inject(HttpClient) })),
withMethods((store) => ({
setQuery(query: string): void {
patchState(store, { query });
},
// RxJS where it pays off: wait for a pause in typing, cancel the request that is no longer needed
_search: rxMethod<string>(
pipe(
map((query) => query.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) {
patchState(store, { results: [], requestStatus: 'idle', requestError: null });
return EMPTY;
}
patchState(store, setPending());
return store._http.get<BookPage>(`${apiUrl}/books`, { params: { q: query } }).pipe(
// handles the error inside the inner stream, so the outer stream keeps running
tapResponse({
next: (page) => patchState(store, { results: page.items }, setFulfilled()),
error: (error) => patchState(store, { results: [] }, setError(error)),
}),
);
}),
),
),
})),
withHooks({
// Called with a signal: the stream gets every new query; cleaned up with the store's injector
onInit: (store) => store._search(store.query),
}),
);
rxMethod<string> takes a chain of operators and returns a method that, like signalMethod, accepts a value, a signal, a computation or an Observable. It is called once in onInit with the store.query signal; after that, every time setQuery changes the state, the new value enters the stream. How debounceTime, distinctUntilChanged and switchMap behave is covered in the RxJS series posts "Higher-Order Mapping: Choosing Between switchMap, mergeMap, concatMap and exhaustMap" and "Time-Based Operators and Schedulers"; what matters here is writing the result of the request into the store with patchState. _search is private: components only see setQuery.
The store is in the search box component's own providers:
@Component({
selector: 'app-search-box',
// Component-level store: created with this component, destroyed with it
providers: [SearchStore],
This is a real example of the component-level store that the previous post only mentioned as an exercise: every search box has its own state, and when the box is destroyed, the store and the rxMethod subscription inside it go with it. A session on the cart page (so that there is no catalog request):
0.1 s request: GET /categories
typed "d" - | -
1.3 s console: [search] idle -> pending
1.3 s request: GET /books?q=dun
1.6 s console: [search] pending -> fulfilled
typed "dun" [Dune] | -
2.3 s console: [search] fulfilled -> pending
2.3 s request: GET /books?q=the
2.6 s console: [search] pending -> fulfilled
replaced with "the" [The Hobbit] | -
3.3 s console: [search] fulfilled -> pending
3.3 s request: GET /books?q=em
3.6 s console: [search] pending -> fulfilled
replaced with "em" [Emma], Middlemarch | -
ArrowDown Emma, [Middlemarch] | -
4.6 s console: [search] fulfilled -> pending
4.6 s request: GET /books?q=mi
4.9 s console: [search] pending -> fulfilled
replaced with "mi" [Middlemarch] | -
5.2 s request: GET /books/9780141439549
5.2 s request: GET /books/9780141439549/reviews
5.5 s console: [search] fulfilled -> idle
5.5 s request: GET /books?category=classics
Enter -> /books/9780141439549 | Middlemarch
6.0 s request: GET /books/9780141439587
6.0 s request: GET /books/9780141439587/reviews
6.3 s request: GET /books?category=classics
related link -> Emma
6.8 s request: GET /books?page=1&sort=title
catalog: Recently viewed: Emma, Middlemarch
A single letter sends no request; "dun" was typed in three keystrokes, but after the 300 ms pause only one GET /books?q=dun went out. The square brackets show the suggestion selected with the keyboard; we will get to that shortly. Enter opened the selected book's page and cleared the box (fulfilled -> idle). The last line is signalMethod's work: two details pages were visited, and the catalog shows the latest one first.
tapResponse comes from @ngrx/operators: it runs the success and error callbacks in a tap and swallows the error with catchError(() => EMPTY). The operator sits on the inner stream, the HTTP Observable that switchMap returns; the reason is in "Common Pitfalls". NgRx 22 removed the old callback form of tapResponse (tapResponse(next, error)); only the object form is left. mapResponse from the same package maps the result to a value instead (one value on success, another on error) and is meant for flows that produce events or actions; we will use it with the Events plugin in the next post.
signalMethod or rxMethod? The NgRx documentation describes signalMethod as rxMethod without RxJS; it adds no RxJS operators to the bundle. For a synchronous update (recently viewed) that is enough. For async work that needs timing, cancellation or ordering (search, save requests arriving one after another), RxJS operators are clearly stronger: signals only propagate the last value of synchronous changes, and they have no counterpart to switchMap or concatMap.
A Custom Feature: withRequestStatus()
Both the search and the coupon make a request, and for both we ask the same questions: is the request running, has it finished, what was the error? Instead of writing this again in every store, we put it into a custom feature:
import { HttpErrorResponse } from '@angular/common/http';
import { computed } from '@angular/core';
import { signalStoreFeature, withComputed, withState, type SignalStoreFeatureType } from '@ngrx/signals';
export type RequestStatus = 'idle' | 'pending' | 'fulfilled' | 'error';
export type RequestStatusState = { requestStatus: RequestStatus; requestError: string | null };
// Reusable feature: every store that talks to the API gets the same status fields
export function withRequestStatus() {
return signalStoreFeature(
withState<RequestStatusState>({ requestStatus: 'idle', requestError: null }),
withComputed(({ requestStatus }) => ({
isPending: computed(() => requestStatus() === 'pending'),
})),
);
}
// Updaters are plain functions, so they combine with other updaters in a single patchState call
export function setPending(): RequestStatusState {
return { requestStatus: 'pending', requestError: null };
}
export function setFulfilled(): RequestStatusState {
return { requestStatus: 'fulfilled', requestError: null };
}
export function setError(error: unknown): RequestStatusState {
const message = error instanceof HttpErrorResponse ? `HTTP ${error.status}` : String(error);
return { requestStatus: 'error', requestError: message };
}
// NgRx 22: the members this feature adds, reusable as the input type of another feature
export type RequestStatusFeature = SignalStoreFeatureType<typeof withRequestStatus>;
signalStoreFeature merges features such as withState and withComputed into a single feature; once it is added to a store, requestStatus, requestError and isPending become part of that store. The part that changes state is not the feature's methods but plain functions: setPending(), setFulfilled(), setError(error). The NgRx documentation recommends this for custom features; the reasons are tree-shaking, easy testing and, most importantly, that they combine with other updaters in a single patchState call. patchState(store, { results: page.items }, setFulfilled()) in the search store and patchState(store, { coupon }, setFulfilled()) in the cart are examples: the result and the status change together, and no intermediate state such as "fulfilled but no result" is ever visible.
The status list in the plan is idle | pending | fulfilled | error; we keep the error message in a separate requestError field. Putting the message into the union ({ error: string }) is possible too, but because of the NgRx 22 change we saw in the previous post, the signal of a union containing an object is now DeepSignal | Signal and needs narrowing in the template; plain string fields avoid that entirely.
A Feature with Input: SignalStoreFeatureType and watchState
The second feature writes every transition of requestStatus to the console. It cannot work on its own; it expects withRequestStatus() in the store:
import { signalStoreFeature, type, watchState, withHooks } from '@ngrx/signals';
import type { RequestStatusFeature } from './with-request-status';
// Requires withRequestStatus() earlier in the same store; the compiler checks it
export function withRequestLog(name: string) {
return signalStoreFeature(
type<RequestStatusFeature>(),
withHooks({
onInit(store) {
let previous = store.requestStatus();
// watchState is synchronous: it sees every patchState call, not only the last one in a tick
watchState(store, ({ requestStatus, requestError }) => {
if (requestStatus === previous) return;
console.log(`[${name}] ${previous} -> ${requestStatus}${requestError ? ` (${requestError})` : ''}`);
previous = requestStatus;
});
},
}),
);
}
The first argument of signalStoreFeature is the feature's input: "a store that uses this feature must have this state, these props and these methods". SignalStoreFeatureType, new in NgRx 22, extracts the type of everything a feature function adds; with type<RequestStatusFeature>() we use the type of withRequestStatus() as the input without writing it out again. The coupon flow in the cart with these two features:
cart - | - | Total: 770 TRY
2.1 s console: [cart] idle -> pending
2.4 s console: Failed to load resource: the server responded with a status of 404 (Not Found)
2.4 s console: [cart] pending -> error (HTTP 404)
apply FREE50 Coupon not applied (HTTP 404) | - | Total: 770 TRY
2.6 s console: [cart] error -> pending
3.0 s console: [cart] pending -> fulfilled
apply BOOKWEEK - | BOOKWEEK: -77 TRY | Total: 693 TRY
after reload - | BOOKWEEK: -77 TRY | Total: 693 TRY
The input is a contract, and the compiler checks it. Adding withRequestLog to a store that has no withRequestStatus (output shortened):
✘ [ERROR] TS2769: No overload matches this call.
Overload 1 of 45, '(f1: SignalStoreFeature<EmptyFeatureResult, { state: { isbns: string[]; }; props: {}; methods: {}; }>, f2: SignalStoreFeature<{ state: { isbns: string[]; }; props: {}; methods: {}; }, EmptyFeatureResult>): Type<...>', gave the following error.
...
Type '{ isbns: Signal<string[]>; }' is missing the following properties from type '{ requestStatus: Signal<RequestStatus>; requestError: Signal<string | null>; }': requestStatus, requestError
...
src/app/catalog/wishlist-store.ts:5:29:
5 │ export const WishlistStore = signalStore(withState({ isbns: [] as s...
╵ ~~~~~~~~~~~
The message is long, but its end is clear: the store's state has no requestStatus and no requestError. Order matters too: features are applied in sequence, so writing withRequestLog before withRequestStatus gives the same error.
We used watchState for the log instead of effect. An effect that reads getState(store) runs only once, with the final state, when the state changes several times in the same tick; the NgRx documentation calls this glitch-free behaviour, and for saving the cart to localStorage it is exactly what we want. watchState, on the other hand, is synchronous: it runs the watcher on every patchState call. For a transition log, undo/redo or anything that has to see every change, it is the right tool. watchState also needs an injection context and stops by itself when the injector is destroyed; onInit satisfies that. NgRx 22.0 has one more fix here: watchers now run inside untracked, outside any reactive context, so even when patchState is called from inside an effect, the signals read in a watcher are not added to that effect's dependencies.
withFeature and withLinkedState: Keyboard Selection
Moving through the suggestions with the arrow keys is a behaviour other lists in Bookstore can use as well (the category menu, unit selection in the institutional portal). So we wrote it as a feature that does not know where the list comes from:
import { computed, type Signal } from '@angular/core';
import { patchState, signalStoreFeature, withComputed, withLinkedState, withMethods } from '@ngrx/signals';
// Arrow-key selection for any list; the list comes from the store through withFeature
export function withKeyboardSelection<T>(items: Signal<T[]>) {
return signalStoreFeature(
// Linked state: back to the first item whenever the list changes, but still writable
withLinkedState(() => ({ highlighted: (): number => (items().length ? 0 : -1) })),
withComputed(({ highlighted }) => ({
highlightedItem: computed(() => items()[highlighted()] ?? null),
})),
withMethods((store) => ({
move(step: 1 | -1): void {
const count = items().length;
if (count) patchState(store, { highlighted: (store.highlighted() + step + count) % count });
},
})),
);
}
The feature takes the list as a Signal<T[]> parameter; the search store wires it with withFeature(({ results }) => withKeyboardSelection(results)). The withFeature callback receives the store built so far and returns a feature. The difference: a feature that declares its input with type<...>() requires fields with specific names in the store (the name requestStatus is fixed), while withFeature passes the value as a parameter without tying the feature to the store's internal structure. Tomorrow the same feature can be used on another list with withFeature(({ categories }) => withKeyboardSelection(categories)).
highlighted is linked state. NgRx wraps the computation function given to withLinkedState in a linkedSignal: every time the list changes, the value is recomputed (the first suggestion is selected), but in between it can be written with patchState (the arrow keys). It is the in-store counterpart of the linkedSignal from the second post and behaves like a normal state field: it shows up in getState and is updated with patchState. The search output shows both behaviours in a row: for "em" Emma was selected, ArrowDown moved to Middlemarch, and when the new query "mi" arrived the selection went back to the first suggestion. One detail: if you write () => (items().length ? 0 : -1), TypeScript infers the type 0 | -1 and the patchState in move does not compile; that is why the return type is written as number.
In Production
In the "In Production" section of the previous post we saw a user's cart silently disappear when the shape of the persisted cart changed. Moving to entities changed the shape once more: getState(store) now contains _lineIds, _lineEntityMap, requestStatus and requestError. Writing these to localStorage is two mistakes: it turns the internal structure into a contract, and the 'pending' status of an unfinished request comes back after a reload. So the code now saves a small, versioned copy instead of the state itself ({ version: 2, items, coupon }), and readSavedCart can read every shape the app has ever written. Opening the app with two carts saved by earlier posts:
v0 array (01-04) : Cart (2) | {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2}],"coupon":null}
v1 object (05) : Cart (2) | {"version":2,"items":[{"isbn":"9780441172719","title":"Dune","price":450,"quantity":2}],"coupon":{"code":"BOOKWEEK","percent":10}}
Both old shapes were read and converted to v2 on the first save; the coupon from post 05 was kept as well. This migration code should stay for several releases, until you are sure that practically no browser still has the old shape saved.
The lifetime of rxMethod and signalMethod subscriptions is tied to the injector they were called in. Calling a root store's reactive method with a signal from a component, when the call is not in the component's injection context, leaves behind a watcher that keeps running after the component is destroyed. Keep the rule simple: call reactive methods in the constructor or a field initializer, or pass { injector }. This is one reason we provided the search store at the component level: the store and all of its subscriptions close with the box.
Keep an eye on bundle size as well: rxMethod and @ngrx/operators add RxJS operators to the bundle. Since the app already uses RxJS through HttpClient, the difference is small here; but a store that uses rxMethod only for synchronous work would do the same job with less code using signalMethod.
Common Pitfalls
Not catching the error inside rxMethod
The search chain without tapResponse, the "it works anyway" version:
map((query) => query.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((query) => {
if (query.length < 2) return of({ items: [] as Book[] });
patchState(store, setPending());
return store._http.get<BookPage>(`${apiUrl}/books`, { params: { q: query } });
}),
// wrong: no error handling; a failed request ends the whole stream
tap((page) => patchState(store, { results: page.items }, setFulfilled())),
Starting the mock API so that the first search request answers 503 (FAIL="GET /api/books:1") and typing three queries:
0.1 s request: GET /categories
0.4 s console: [search] idle -> pending
0.4 s request: GET /books?q=em
0.7 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
0.7 s console: ERROR HttpErrorResponse
typed "em" - | Searching... | -
typed "emm" - | Searching... | -
typed "dune" - | Searching... | -
The first request failed and the error left switchMap for the outer stream. Since the rxMethod subscription has no error callback, the error landed in the console as an uncaught error (ERROR HttpErrorResponse) and the stream closed. No request was sent for the next two queries; "Searching..." stayed on screen forever. The same scenario with the tapResponse of the real code:
0.1 s request: GET /categories
0.4 s console: [search] idle -> pending
0.4 s request: GET /books?q=em
0.7 s console: Failed to load resource: the server responded with a status of 503 (Service Unavailable)
0.7 s console: [search] pending -> error (HTTP 503)
typed "em" - | - | Search failed (HTTP 503)
1.3 s console: [search] error -> pending
1.3 s request: GET /books?q=emm
1.6 s console: [search] pending -> fulfilled
typed "emm" Emma | - | -
2.2 s console: [search] fulfilled -> pending
2.2 s request: GET /books?q=dune
2.6 s console: [search] pending -> fulfilled
typed "dune" Dune | - | -
The error was caught in the inner stream and written to the state, and the next query worked normally. The rule is the same as in the RxJS series post "Error Handling in RxJS: catchError, retry with Backoff, finalize and Catching Errors Without Killing the Stream": an error belongs to each request's own stream, not to the outer stream.
Hidden dependencies between features
withRequestLog declares its input explicitly with type<RequestStatusFeature>(), and the compiler catches what is missing. Writing the same feature without an input, with an access such as (store as any).requestStatus() inside, compiles, but blows up at runtime in the wrong store. The NgRx documentation recommends independent features; when a dependency is really needed, make it visible with an input type. When several features that have an input but no generic parameter are used in the same store, a known TypeScript issue can appear; the documented fix is to give the feature an unused generic (withX<_>()).
Saying "one more" with addEntity
The answer to "Predict First": addEntity and prependEntity silently skip an existing identifier. To change an existing one use updateEntity, to replace it completely setEntity, to merge fields upsertEntity.
Saving getState directly
Writing getState(store) as it is for persistence looks like the shortest path. But when a feature is added (like withRequestStatus in this post), the saved shape silently changes too. Define the shape to be saved as a separate type and version it.
A Small Experiment
★☆☆ Make the limit (3) in RecentlyViewedStore configurable from outside the store: add a _limit with withProps and use it in track. Verify with Playwright that opening the same book twice in a row leaves a single entry in the list.
★★☆ Add withKeyboardSelection to the category menu: write a small component-level store and wire the list of the Categories service with withFeature. Observe that the selection goes back to the first item when the categories are reloaded.
★★★ Add "save" to the cart: write an rxMethod that sends the cart to the server on every change and try it three times, with switchMap, concatMap and exhaustMap. Add a small POST /api/cart to the mock API and log what reaches the server, and in which order, for quick successive changes; which one is right for a cart?
In the next post we move on to the relationship between stores: with the NgRx Signal Store Events plugin, an "order placed" event in Bookstore's checkout will empty the cart, add a record to the orders store and show a notification; we will compare the same flow with a version written with methods.