In an NgRx SignalStore, how do you build a debounced product search with rxMethod, and why wrap the request in tapResponse?
answer
- operator chain becomes a method
- call it once with a signal
- one subscription for its lifetime
- catch on the inner observable
- object-form observer from @ngrx/operators
basics
~20 sDefine search = rxMethod<string>(pipe(debounceTime, distinctUntilChanged, switchMap to the API)) and call it once with the query signal. tapResponse on the inner request handles errors so they never reach the method's single subscription and kill it.
solid answer
~30 s`rxMethod` from `@ngrx/signals/rxjs-interop` turns an operator chain into a method that accepts a value, a signal, a computation function or an observable. In `withMethods` I define `search: rxMethod<string>(pipe(debounceTime(300), distinctUntilChanged(), tap(setLoading), switchMap(q => api.search(q).pipe(tapResponse({ next, error })))))` and call `store.search(store.query)` once in `onInit`, so each new query value flows through. The chain is subscribed once for the method's lifetime, so an error reaching it would end that subscription and the search would silently stop. `tapResponse` from `@ngrx/operators` calls my `error` callback and completes only the inner request. In NgRx 22 only the object form `tapResponse({ next, error })` exists.
code
ts · 29 linesimport { inject } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { tapResponse } from '@ngrx/operators';
import { debounceTime, distinctUntilChanged, pipe, switchMap, tap } from 'rxjs';
import { Product, ProductsApi } from './products-api';
export const ProductSearchStore = signalStore(
withState({ query: '', results: [] as Product[], loading: false }),
withMethods((store, api = inject(ProductsApi)) => ({
setQuery: (query: string) => patchState(store, { query }),
search: rxMethod<string>(
pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => patchState(store, { loading: true })),
switchMap((query) =>
api.search(query).pipe(
tapResponse({
next: (results) => patchState(store, { results, loading: false }),
error: () => patchState(store, { results: [], loading: false }),
})
)
)
)
),
})),
withHooks({ onInit: (store) => store.search(store.query) })
);go deeper
Recall that rxMethod turns RxJS operators into a store method you can call with a signal, and that tapResponse handles the success and error of a request.
Explain the single subscription, why an escaped error silently kills the method, and why tapResponse belongs on the inner observable.
Show production care: injection-context rules for calls with signals, loading flags that survive cancellation, and choosing signalMethod when no RxJS is needed.
Set conventions for side effects in stores: when rxMethod is warranted, which error handling is mandatory, and how the team keeps search behaviour consistent.
## What rxMethod is `rxMethod` comes from `@ngrx/signals/rxjs-interop`. It turns a chain of RxJS operators into a **reactive method**: a function you can call with a static value, a signal, a computation function or an observable. Inside a SignalStore it usually lives in `withMethods`, whose factory runs in an injection context, so the method is tied to the store's lifetime. How it works internally matters for the rest of the answer: - It creates **one internal subject** and subscribes your operator chain to it **once**, when the method is created. - A **static value** is pushed into that subject once. - A **signal or computation function** is watched with an Angular `effect`, and every new value is pushed in. - An **observable** is subscribed, and each emission is pushed in. - The subscription ends when the injector that created the method is destroyed, or when you call the method's `destroy()`. ## Building the debounced product search 1. Keep `query` in store state and expose `setQuery(query)` as a method, because state is protected by default. 2. Define `search: rxMethod<string>(pipe(...))` in `withMethods`. 3. In the pipe: `debounceTime(300)` so a burst of keystrokes becomes one value, `distinctUntilChanged()` so the same query is not fetched twice, a `tap` that sets `loading: true`, then a flattening operator that calls the API. For a typeahead that is `switchMap`, which drops the stale request when a newer query arrives. 4. Wrap the API observable in `tapResponse` from `@ngrx/operators`, writing results or the failure into state. 5. In `withHooks({ onInit(store) { store.search(store.query); } })`, call the method **once with the signal**. From then on each new `query` value flows through the pipeline; no subscription code lives in the component. ## Why tapResponse, and why inside the inner observable Because the chain is subscribed **once**, an error that reaches the outer pipe **terminates that one subscription**. rxMethod does not resubscribe. The method keeps accepting calls, but nothing is listening any more: after the first failed request, the search silently stops working until the store is recreated. `tapResponse` prevents that. It is a small wrapper that: - calls `next` for each value and, optionally, `complete` when the inner observable completes; - catches an error, calls your `error` callback and **replaces the error with an empty, completed observable**, so the error never reaches the outer chain; - optionally runs a `finalize` callback. Placed on the **inner** observable (inside `switchMap`), it ends only that request. The outer stream stays alive for the next query. Placed on the outer chain, it would catch the error but still complete the stream, which kills the method just the same. `tapResponse` also enforces that you handle the error case, because `error` is a required property of its observer object. ## The NgRx 22 signature | Form | Status in NgRx 22 | |---|---| | `tapResponse({ next, error, complete?, finalize? })` from `@ngrx/operators` | the only form | | `tapResponse(next, error, complete?)` positional callbacks | deprecated in 20, **removed in 22** | | `tapResponse` from `@ngrx/component-store` | removed in 18; it moved to `@ngrx/operators` | ## Loading flags and cancellation It is tempting to reset `loading` in `finalize`. With `switchMap` that is a bug: when a new query cancels an in-flight request, the cancelled request's `finalize` runs **after** the new query's `tap` already set `loading: true`, so the spinner turns off while the new request is still pending. Resetting `loading` in `next` and `error` instead avoids it, because a cancelled request calls neither. ## Lifecycle rules worth knowing - Creating an `rxMethod` outside an injection context requires passing `{ injector }` as its second argument. - Since NgRx 21.1, **calling** it with a signal or observable outside an injection context without an injector is deprecated and logs a dev-mode warning; a future version will throw. Call it in a constructor, field initializer or `onInit` hook, or pass `{ injector }`. - `rxMethod<void>` defines a method that takes no argument, for a "reload" button. - Each call that passes a signal or observable returns a reference with its own `destroy()`, so you can stop one input source while other calls keep running; the method's own `destroy()` stops everything. - If a store method only needs to run synchronous code whenever a signal changes, with no debounce and no cancellation, `signalMethod` from `@ngrx/signals` is the RxJS-free alternative.
- When would signalMethod replace rxMethod for a SignalStore side effect?`signalMethod` from `@ngrx/signals` accepts a value, a signal or a computation function, but no observable, and runs a plain callback through an Angular `effect`, without RxJS. That suits synchronous reactions such as saving the current filter to `localStorage`. Because effects are glitch-free, only the last of several synchronous changes arrives, and there are no operators for debouncing or cancelling requests, so a typeahead search stays with `rxMethod`.
- Why is resetting loading in tapResponse's finalize callback a bug here?When a new query arrives, the pipe first runs the `tap` that sets `loading: true`, then `switchMap` unsubscribes the in-flight request. That unsubscription runs its `finalize`, which sets `loading: false` while the new request is still pending. Resetting `loading` in `next` and `error` avoids it, because a cancelled request calls neither.
- What happens if a component calls store.search(someSignal) from ngOnInit instead of its constructor?`ngOnInit` is not an injection context. Since NgRx 21.1 this logs a dev-mode deprecation warning, and the watcher falls back to the injector that created the method, here the store's, so it lives as long as the store instead of the component. Pass `{ injector }` as the second argument, or make the call in the constructor or a field initializer.
saying these in an interview costs you the question
- rxMethod resubscribes its pipeline automatically after an error
- tapResponse on the outer pipe keeps the method alive after a failure
- tapResponse(next, error) positional callbacks are the current NgRx 22 form
- You must call search() on every keystroke from the template
- rxMethod comes from @ngrx/operators alongside tapResponse