---
title: "Signals Without the RxJS War"
description: "A production boundary for Signals, computed values, RxJS streams, and interop, without rewriting Observable contracts just to look current."
deck: "Signals are excellent for current state and synchronous derivation, and shipped stable in Angular v20 (May 2025). RxJS is still the better language for debounce, cancellation, polling, retry, and events over time. The boundary is the design decision; the rest is consequence."
author: "André Ramos"
url: "https://andreramos.dev/angular/signals-without-rxjs-war/"
lang: "en"
type: "article"
---

# Signals Without the RxJS War

> Signals are excellent for current state and synchronous derivation, and shipped stable in Angular v20 (May 2025). RxJS is still the better language for debounce, cancellation, polling, retry, and events over time. The boundary is the design decision; the rest is consequence.

## The wrong question

The least useful version of this conversation is asking whether [Signals](https://angular.dev/guide/signals) replace [RxJS](https://rxjs.dev). That question makes teams rewrite Observable contracts or force async workflows into Signals just to look current.

The better question is: what kind of problem is this state solving? If it is a current value or a synchronous derivation, Signals usually fit better. If it is a stream over time with debounce, cancellation, retry, polling, or WebSocket semantics, RxJS is still the tool I want.

Alex Rickabaugh (Angular team) framed the line the same way on [JS Party episode 310](https://changelog.com/jsparty/310): 'Signals are values that can change over time, and RxJS observables really give you notifications of events happening at a specific point in time.' The framework's own team teaches Signals and RxJS as different primitives, not competing ones.

## The split I use

| Situation | Default choice | Reason |
|---|---|---|
| Active tab, filter text, expanded row | Signal | Local UI state with one current value. |
| Derived label, permission flag, filtered count | computed | Synchronous derivation from other values. |
| Existing user$ or permissions$ service | toSignal at the component boundary | Keep the service contract and simplify template reads. |
| Autocomplete with debounce and cancellation | RxJS pipeline plus Signal for UI state | Time and cancellation belong in the stream. |
| NgRx or ComponentStore already coordinating a feature | Both | Keep the store backbone; expose Signals where they help consumption. |

## The boundary I like

A useful team policy is simple enough to remember in code review: use [Signals](https://angular.dev/guide/signals) for local and derived component state; keep RxJS for streams, async composition, events over time, NgRx integration, and cancellation-heavy workflows.

Interop earns its place when it makes the boundary explicit via [`toSignal`](https://angular.dev/api/core/rxjs-interop/toSignal) and [`toObservable`](https://angular.dev/api/core/rxjs-interop/toObservable). It is not a mandate to convert the whole application. A component can read a `user` Signal derived from `user$` while the authentication service keeps exposing Observables because that is still the right contract for the rest of the app.

When the Angular team communicates adoption, it tends to communicate with production data. In the [v20 announcement](https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301), YouTube reported a 35% improvement in interaction latency on Living Room after switching to Angular Signals via Wiz. That result lands when the team treats Signals as the right primitive for UI state, not when it tries to retrofit Observable state into Signal shape.

In the search example, normalize the query before `distinctUntilChanged()`. Otherwise `angular` and `angular ` become different requests, and the stream is doing avoidable work.

*Observable service, Signal component boundary*

```ts
export class HeaderComponent {
  private readonly auth = inject(AuthService);

  readonly user = toSignal(this.auth.user$, { initialValue: null });
  readonly canAdmin = computed(() =>
    this.user()?.roles.includes('admin') ?? false
  );
}
```

*Signal input, RxJS timing*

```ts
private readonly api = inject(SearchApi);
readonly query = signal('');
private readonly query$ = toObservable(this.query);

readonly results = toSignal(
  this.query$.pipe(
    map(query => query.trim()),
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(term => {
      if (!term) return of([]);

      return this.api.search(term).pipe(
        catchError(() => of([]))
      );
    })
  ),
  { initialValue: [] }
);
```

## The trap

Pawel Kozlowski (Angular team, [RFC Signals author](https://github.com/angular/angular/discussions/49685)) put the distinction concretely on [JS Party 310](https://changelog.com/jsparty/310): 'A signal — I can always look at this box, and I'm going to get the value. If I subscribe to an event emitter, there was no event fired, I don't have a value.' That distinction is what makes [`effect`](https://angular.dev/api/core/effect) the wrong tool for keeping two Signals in sync.

[`effect`](https://angular.dev/api/core/effect) is not the new `subscribe` for everything. The smell is an effect that exists only to keep another Signal in sync. Use it when reactive state needs to touch an external system: localStorage, analytics, a canvas, or an imperative library. If the rule is derivation, prefer [`computed`](https://angular.dev/api/core/computed). If the rule is time, prefer RxJS.

## Reusable artifact: Signals/RxJS boundary rule

- Value now: Signal.
- Value derived now: computed.
- Events over time: RxJS.
- Legacy Observable consumed by a component: toSignal at the boundary.
- External side effect from reactive state: effect, sparingly.

## Sources checked

- https://angular.dev/guide/signals
- https://angular.dev/api/core/signal
- https://angular.dev/api/core/computed
- https://angular.dev/api/core/effect
- https://angular.dev/api/core/rxjs-interop/toSignal
- https://angular.dev/api/core/rxjs-interop/toObservable
- https://angular.dev/api/core/rxjs-interop/rxResource
- https://rxjs.dev/api/index/class/BehaviorSubject
- https://github.com/angular/angular/discussions/49685
- https://github.com/angular/angular/discussions/60121
- https://blog.angular.dev/announcing-angular-v20-b5c9c06cf301
- https://changelog.com/jsparty/310

## Related

- [HTTP State Without Turning Everything Into Signals](https://andreramos.dev/angular/http-state-without-signals-everywhere/)
- [NG0950: Reading a Required Input Before Angular Sets It](https://andreramos.dev/angular/angular-ng0950-required-input-not-set/)
- [What to Adopt, Spike, or Wait On in Angular 21](https://andreramos.dev/angular/adopt-spike-wait-angular-21/)
