All articles

From RxJS to Signals: a practical guide to migrating state in Angular

Introduction: the reactivity revolution in Angular

Since Angular's earliest versions, RxJS has been the engine of reactivity: HTTP calls, forms, the router and events all went through Observables. A very powerful tool, but a demanding one: showing a simple derived value required subjects, operators and subscriptions to close.

With Signals the Angular team introduced a simpler model for application state: a value you read and update, on which other values depend automatically. The result is less code, easier reading and the foundations for an Angular without Zone.js. In this article we'll see how and when to move state from RxJS to Signals, with a complete refactoring.

RxJS vs Signals: two tools for different problems

The most useful distinction is this: RxJS describes events over time — clicks, WebSocket messages, HTTP responses, keystrokes to throttle — while Signals describe the current state and the values derived from it. They aren't competitors: they solve different problems, as I explained in more detail in this article.

RxJSSignalsNotes
BehaviorSubject<T>signal<T>()Current value, readable synchronously
combineLatest() + map()computed()Dependencies tracked automatically
.subscribe() for side effectseffect()Only towards the outside world
async pipereading value() in the templateNo subscriptions to manage
debounceTime, switchMapstay in RxJSBridged with toObservable

There's also a change detection advantage: when a template reads a signal, Angular knows exactly which components depend on that value and refreshes only those, instead of re-checking the whole component tree on every event.

Anatomy of the refactoring: a real case

The scenario is a classic: a dashboard with a product list, a text search field and a count of the filtered items. Here's the "old-school" RxJS version:

@Component({ /* ... */ })
export class ProductDashboardComponent implements OnInit, OnDestroy {
  private readonly products$ = new BehaviorSubject<Product[]>([]);
  private readonly filter$ = new BehaviorSubject<string>('');
  private readonly destroy$ = new Subject<void>();

  visible: Product[] = [];
  count = 0;

  constructor(private api: ProductApi) {}

  ngOnInit(): void {
    this.api.list().pipe(takeUntil(this.destroy$))
      .subscribe(list => this.products$.next(list));

    combineLatest([this.products$, this.filter$.pipe(debounceTime(300))])
      .pipe(
        map(([list, term]) =>
          list.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))),
        takeUntil(this.destroy$),
      )
      .subscribe(visible => {
        this.visible = visible;
        this.count = visible.length;
      });
  }

  onFilter(term: string): void { this.filter$.next(term); }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

It works, but it has the typical flaws of this approach:

  • A lot of boilerplate: two subjects, a destroy subject, two subscriptions and an ngOnDestroy.
  • A flow that's hard to follow: to understand where count comes from you have to read the whole chain of operators.
  • Risk of memory leaks: forget one takeUntil and a subscription stays alive after the component is destroyed.
  • Duplicated state: visible and count are copies to keep in sync by hand.

Step by step: state with Signals

Step 1: values and inputs become signals

Every BehaviorSubject that represents state becomes a writable signal. You update it with .set() when replacing the value and with .update() when the new value depends on the previous one:

readonly products = signal<Product[]>([]);
readonly filter = signal('');

onFilter(term: string): void {
  this.filter.set(term);                         // replace the value
}

addProduct(p: Product): void {
  this.products.update(list => [...list, p]);    // derive from the previous one
}

Step 2: derived logic becomes computed

The combineLatest with map becomes a computed(). Dependencies aren't declared: Angular discovers them by tracking which signals are read. On top of that a computed is lazy and memoised: it's recalculated only when a dependency changes, and only if someone reads it.

readonly visible = computed(() => {
  const term = this.filter().toLowerCase();
  return this.products().filter(p => p.name.toLowerCase().includes(term));
});

readonly count = computed(() => this.visible().length);

// Template: no async pipe, no subscriptions
// @for (p of visible(); track p.id) { <app-product-card [product]="p" /> }
// <span>{{ count() }} products</span>

visible and count are no longer copies to keep in sync: they're always consistent by construction.

Step 3: side effects, with effect

effect() is for talking to the outside world: saving to localStorage, writing a log, syncing a third-party library such as a chart or a map. It isn't for keeping two signals aligned: in that case the answer is almost always a computed.

constructor() {
  // OK: a side effect towards the outside world
  effect(() => localStorage.setItem('dashboard.filter', this.filter()));
}

// Anti-pattern: an effect that copies one signal into another
effect(() => this.total.set(this.visible().length));

// Better: derive it, and let Angular keep it in sync
readonly total = computed(() => this.visible().length);

The bridge between the two worlds: rxjs-interop

Calls to the backend — a REST or NestJS API — naturally stay Observables: HttpClient returns streams, and that's where cancellation, retries and time operators belong. The @angular/core/rxjs-interop package offers two bridges:

  • toSignal(): turns a stream, such as an HTTP response, into a signal you can use directly in the template.
  • toObservable(): turns a signal into a stream when you need time operators such as debounceTime.

Here's a real-time search using both: the search field is a signal, debouncing and request cancellation stay in RxJS, and the result goes back to the template as a signal. Both functions must be called in an injection context, for instance as field initialisers.

private readonly api = inject(ProductApi);

readonly query = signal('');

// signal → Observable (time operators) → signal (template)
readonly results = toSignal(
  toObservable(this.query).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(q => this.api.search(q)),
  ),
  { initialValue: [] as Product[] },
);

// Template: <input (input)="query.set($any($event.target).value)" />
//           @for (p of results(); track p.id) { ... }

Architectural pattern: a signal-based store

When state is needed by several components, the logic moves into a service. The rule is to encapsulate: the writable signal stays private, a read-only version is exposed with .asReadonly(), and changes go only through the service's methods.

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<CartItem[]>([]);

  readonly items = this._items.asReadonly();   // read-only from outside
  readonly total = computed(() =>
    this._items().reduce((sum, i) => sum + i.price * i.qty, 0));

  add(item: CartItem): void { this._items.update(list => [...list, item]); }
  clear(): void { this._items.set([]); }
}

For larger applications, Signal Store from @ngrx/signals brings the same model to the global level, with a standard structure for state, derived values and methods.

Conclusions and checklist

  1. Use Signals for synchronous state and the UI: current values, filters, selections, counts.
  2. Use RxJS for asynchronous events and flows over time: HTTP, WebSockets, debouncing, cancellation. RxJS operators remain invaluable.
  3. Use computed for everything derived, never an effect that copies values.
  4. Make the two worlds cooperate with rxjs-interop instead of rewriting everything.
  5. Migrate one component at a time, starting with those that have the most manual subscriptions.

Have you already migrated part of your application to Signals, or are you unsure where to draw the line with RxJS? Leave a comment below with your experience: the most interesting questions will become ideas for future articles.

💬 Reader notes

0 notes

Write a note

Share your opinion, a suggestion or a compliment

Latest notes

No notes yet. Be the first to comment!