3 min read Emadideen Ghannam

Signals finally let me write Angular templates without crying

After years of async-pipe noise and Subject thrash, signals make reactivity match what templates always wanted from us.

A live filter I built years ago had one job: take the user’s query, filter a list of customers, and render the filtered result as they typed. It worked. It also looked like this:

// before: 2018 BehaviorSubject pattern
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class CustomerFilter {
  private query$ = new BehaviorSubject<string>('');
  filtered$ = this.query$.pipe(
    map(q => this.customers.filter(c => c.name.includes(q))),
    distinctUntilChanged(),
    shareReplay({ bufferSize: 1, refCount: true })
  );

  ngOnInit() {
    this.searchInput
      .pipe(takeUntil(this.destroy$))
      .subscribe(q => this.query$.next(q));
  }
}
<!-- the template -->
<ng-container *ngIf="filtered$ | async as filtered; else loading">
  <customer-list [items]="filtered"></customer-list>
</ng-container>
<ng-template #loading>...</ng-template>

Five concepts to render one list. BehaviorSubject. RxJS pipeline. async pipe. structural directive trick to alias the value. ng-template fallback. OnPush so the whole thing actually rendered.

Same component in Angular 21 with signals:

@Component({})
export class CustomerFilter {
  query = signal<string>('');
  filtered = computed(() =>
    this.customers.filter(c => c.name.includes(this.query()))
  );
}
@if (filtered(); as filtered) {
  <customer-list [items]="filtered" />
} @else {
  ...
}

That is not a refactor. That is a different model.

What changed

In the 2018 version, the BehaviorSubject was doing two unrelated jobs. It was a value (current config) AND a stream (changes over time). Templates only ever consume the value. Streams existed because the framework demanded them.

Signals separate the two. signal() is a value with reactive notifications baked in. computed() is derived value with the dependency graph baked in. effect() is the only place where “stream over time” lives, and it lives in code, not in the template.

The async pipe was a hack to bridge a stream into a value at render time. It worked, but it leaked: every async pipe is a subscription you have to reason about. Signals don’t subscribe. They read.

What it does to type narrowing

The thing I missed most about plain TypeScript when I wrote Angular templates was narrowing. if (x) { ... } narrows the type inside the block. *ngIf="x as y" did the same trick, but only in templates and only with one tier. Nested narrowing was a maze.

@if (x(); as x) { ... } is the same if you write in TypeScript. It narrows. It composes. The template stops being its own dialect.

What it doesn’t replace

Signals are not RxJS. There are still problems where you need a stream:

  • Debounced input.
  • HTTP requests that compose, retry, cancel.
  • WebSocket frames.
  • Event buses.

I keep RxJS for those. The autocomplete debounce in my current product still uses observables. Nobody is rewriting those.

What signals replaced is the parts of RxJS that were never about streams. State that just happens to change. Derived values. Things templates already wanted to read directly.

When to migrate

I do not migrate working legacy. The admin app I work on uses BehaviorSubject + async pipe in a thousand places. Migrating it earns the team nothing.

For new code, in any project, on any branch, signals first. If a stream is the right tool, reach for toSignal() or stay in RxJS - whichever makes the consumer cleaner.

Take

Signals didn’t replace RxJS. They replaced the parts of RxJS that were never about streams.


Edit on GitHub (opens in new tab)