RxJS to Angular Signals: A Practical Migration Guide (v22)
A hands-on guide to migrating RxJS- and NgRx-heavy Angular code to Signals in Angular v22 - what maps to what, when to keep RxJS, and the pitfalls to avoid.
If you have shipped Angular apps over the last few years, your state lives in RxJS: BehaviorSubjects in services, combineLatest in components, and async pipes everywhere. It works - but it is also where a lot of complexity, subscription bugs, and ExpressionChangedAfterItHasBeenChecked errors come from.
Angular v22 changes the default. Signals are now the primary reactivity model, zoneless is the default for new apps, and OnPush is the default change detection strategy for new components. The good news: you do not have to rewrite everything at once. RxJS and Signals interoperate cleanly, so you can migrate incrementally.
This guide gives you the mental model and a concrete mapping for moving RxJS code to Signals - and, just as importantly, when not to.
Why migrate at all?
Signals give you fine-grained, synchronous reactivity. Reading a signal is just a function call (count()), there is no subscription to manage, and Angular knows exactly which views depend on which signals - so it updates only what changed. In practice that means:
- No manual subscription lifecycle. No
takeUntil(destroy$), no leaked subscriptions. - Simpler templates. No
asyncpipe gymnastics or nested*ngIf="x$ | async as x". - Fewer change-detection surprises. Signal reads are glitch-free and pull-based.
- Better performance by default in a zoneless,
OnPushworld.
The core mapping
Here is the cheat sheet most migrations come down to:
| RxJS pattern | Signal equivalent |
|---|---|
BehaviorSubject<T>(initial) |
signal<T>(initial) |
subject.next(value) |
sig.set(value) / sig.update(fn) |
combineLatest([a$, b$]).pipe(map(...)) |
computed(() => fn(a(), b())) |
someObservable$ | async in template |
read the signal directly: value() |
tap(...) side effects on a stream |
effect(() => { ... }) |
takeUntil(destroy$) cleanup |
automatic (effects/takeUntilDestroyed()) |
| HTTP-driven state | httpResource() / rxResource() |
1. Local state: BehaviorSubject → signal
Before:
export class CartService {
private readonly items$ = new BehaviorSubject<CartItem[]>([]);
readonly items = this.items$.asObservable();
add(item: CartItem) {
this.items$.next([...this.items$.value, item]);
}
}
After:
export class CartService {
private readonly _items = signal<CartItem[]>([]);
readonly items = this._items.asReadonly();
add(item: CartItem) {
this._items.update((items) => [...items, item]);
}
}
No asObservable(), no .value, no subscription in the consumer - the template just calls items().
2. Derived state: combineLatest → computed
Before:
total$ = combineLatest([this.price$, this.quantity$]).pipe(
map(([price, quantity]) => price * quantity)
);
After:
total = computed(() => this.price() * this.quantity());
computed is memoized and only recalculates when a dependency it actually read changes. No operators, no subscription, no distinctUntilChanged.
3. The template: drop the async pipe
Before:
<p>Total: {{ total$ | async }}</p>
@if (user$ | async; as user) {
<span>{{ user.name }}</span>
}
After:
<p>Total: {{ total() }}</p>
@if (user(); as user) {
<span>{{ user.name }}</span>
}
4. Async data: httpResource instead of a subscription
For HTTP-driven state, Angular v22 gives you httpResource() - a reactive data source that manages loading, error, and value states as signals:
weather = httpResource<Weather>(() => `/api/weather/${this.city()}`);
// template: weather.isLoading(), weather.error(), weather.value()
When city() changes, the request re-runs automatically. No switchMap, no manual loading flags.
Interop: you do not have to go all-in
The migration is incremental because of @angular/core/rxjs-interop:
toSignal(obs$, { initialValue })- wrap an existing Observable as a signal, so new signal-based code can consume legacy streams.toObservable(sig)- expose a signal to code that still expects an Observable.takeUntilDestroyed()- replacetakeUntil(destroy$)boilerplate for the RxJS you keep.
liveUsers = toSignal(this.userService.liveUsersCount$, { initialValue: 0 });
This lets you migrate service-by-service and component-by-component, with both paradigms coexisting.
When to keep RxJS
Signals are for state; RxJS is still the right tool for events and streams over time. Keep RxJS when you need:
- Debouncing, throttling, or other time-based operators (
debounceTime,auditTime). - Complex event coordination (
switchMap,mergeMap,concatMap, retries, backoff). - WebSockets and long-lived multi-value streams.
A common pattern: handle the stream with RxJS, then toSignal() the result for the view.
Pitfalls to avoid
- Don't
.set()inside acomputed. Computeds must be pure derivations. Useeffect()for side effects. - Mind equality. Signals use
===by default; replacing an object with an equal-by-reference one won't notify. Update immutably (update((s) => ({ ...s, ... }))). - Effects are not for deriving state. If you find yourself writing to a signal from an effect to "compute" something, reach for
computedinstead. mutate()is gone. Onlyset()andupdate()exist now.
A migration playbook
- Start with leaf services that hold simple state (
BehaviorSubject→signal). - Convert their derived values to
computed. - In components, drop
asyncpipes and read signals directly. - Replace HTTP state with
httpResource/rxResource. - Use
toSignal/toObservableat the boundaries while both worlds coexist. - Keep RxJS for genuine event streams;
toSignaltheir output for the view.
Go deeper
This is the condensed version of a migration that, in a real codebase, raises a lot of "what about…?" questions - testing signal-based code, performance in a zoneless app, Signal Forms, and architecting services around Signals.
New to everything that landed in this release? Start with What's New in Angular v22: The Signal-First Release for the full feature tour.
My book Mastering Angular Signals covers all of it, with a dedicated chapter on RxJS/NgRx migration and runnable examples for every concept - fully updated for Angular v22. Get it on Leanpub (DRM-free PDF/EPUB) - also available on Amazon.