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.

Share
RxJS to Angular Signals: A Practical Migration Guide (v22)

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 async pipe 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, OnPush world.

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: BehaviorSubjectsignal

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: combineLatestcomputed

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() - replace takeUntil(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 a computed. Computeds must be pure derivations. Use effect() 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 computed instead.
  • mutate() is gone. Only set() and update() exist now.

A migration playbook

  1. Start with leaf services that hold simple state (BehaviorSubjectsignal).
  2. Convert their derived values to computed.
  3. In components, drop async pipes and read signals directly.
  4. Replace HTTP state with httpResource/rxResource.
  5. Use toSignal/toObservable at the boundaries while both worlds coexist.
  6. Keep RxJS for genuine event streams; toSignal their 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.