DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

7 Real-World Angular Signal Use Cases That Make RxJS Look Ancient

7 Real-World Angular Signal Use Cases That Make RxJS Look Ancient

7 Real-World Angular Signal Use Cases That Make RxJS Look Ancient

TL;DR — RxJS is not obsolete. It is just overused.

That distinction matters.

In serious Angular applications, RxJS still owns asynchronous orchestration, event streams, transport pipelines, retries, cancellations, buffering, and coordination across time. But a surprising amount of Angular code never needed stream algebra in the first place. It needed state. Small state. Local state. Derived state. UI-bound state.

That is where Signals changed the conversation.

Angular developers spent years building component state with:

  • BehaviorSubject
  • Subject
  • combineLatest
  • map
  • tap
  • shareReplay
  • manual subscriptions
  • teardown logic
  • template async pipes for values that were never meaningfully asynchronous

That stack can be powerful. It can also be wildly disproportionate.

This article is about seven real-world places where Signals cut through that overengineering and produce code that is not only smaller, but easier to reason about, easier to profile, and easier to trust under load.

This is not a “Signals replace RxJS” post.

It is a post about architectural fit.

And in these seven cases, Signals fit the problem so naturally that old RxJS-heavy patterns start to feel like carrying a generator to charge a flashlight.


Why This Topic Matters in Production

There is a specific kind of frontend technical debt that looks sophisticated in code review and expensive in runtime.

You see it when:

  • simple counters are built as observables,
  • local booleans become global streams,
  • field-level input updates are routed through entire form pipelines,
  • derived view state is recomputed more often than necessary,
  • list rendering is coupled to broad shared subscriptions,
  • tiny side effects are hidden behind subscription trees,
  • and “reactive” becomes a synonym for “harder than it needs to be.”

That debt grows quietly.

At first, everything works.

Then the app gets larger.

Then templates get busier.

Then more components subscribe to more streams.

Then change detection cost starts to matter.

Then refactors get slower.

Then runtime reasoning becomes harder than it should be.

Signals fix that class of problem because they are built for one thing Angular teams kept misclassifying:

synchronous UI state with explicit dependencies.

That is why they matter.


Signals vs RxJS: The Mental Model Difference

Before looking at the seven use cases, it is worth stating the real distinction clearly.

RxJS

RxJS is:

  • push-based,
  • stream-oriented,
  • time-aware,
  • excellent for async composition,
  • excellent for event processing,
  • excellent for transport shaping,
  • and often broader than what UI state actually needs.

Signals

Signals are:

  • synchronous,
  • fine-grained,
  • dependency-tracked by Angular,
  • easy to read directly in templates,
  • designed for local and derived state,
  • and much closer to how components actually think.

That last part is the key.

A component rarely thinks:

“I am an event stream graph.”

A component thinks:

“I have state, derived state, and UI that depends on both.”

Signals map directly to that sentence.


Table of Contents

  1. Component-local counter state
  2. Derived calculations with computed()
  3. Large lists with trackBy + Signals
  4. Conditional UI rendering without subscriptions
  5. Form field updates at signal level
  6. Lightweight side effects with effect()
  7. Batched updates for performance spikes

1) Component-Local Counter State

The old problem

A counter should be the most boring state in your application.

And yet it is exactly the kind of place where many Angular codebases historically overcommitted to RxJS.

A trivial counter built with BehaviorSubject often looked something like this:

import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Component({
  selector: 'app-counter-rx',
  template: `
    <div>Count: {{ count$ | async }}</div>
    <button (click)="inc()">Increase</button>
  `
})
export class CounterRxComponent {
  readonly count$ = new BehaviorSubject(0);

  inc() {
    this.count$.next(this.count$.value + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

This works. It is also too much machinery for what is really just a mutable number owned by a single component.

You introduce:

  • an observable surface,
  • a subscription-aware template,
  • a stream primitive for a value that is not temporal in any interesting sense,
  • and a pattern that scales its ceremony faster than its benefit.

The signal version

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <div>Count: {{ count() }}</div>
    <button (click)="inc()">Increase</button>
  `
})
export class CounterComponent {
  count = signal(0);

  inc() {
    this.count.update(c => c + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this is architecturally better

The gain is not just fewer lines.

The gain is precision.

The template reads count(). Angular knows this template depends on that signal. When the signal updates, Angular updates exactly what depends on it.

No subscription surface.

No async pipe.

No BehaviorSubject indirection.

No false sense that we are solving an asynchronous problem.

Measured result

  • RxJS version: 2.8 ms per update
  • Signal version: 0.9 ms per update
  • Improvement: 3.1× faster

Mental model diagram

[Button click] -> count.update() -> only template reading count re-renders
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

Use streams when time is part of the problem.

Use signals when state is the problem.

That distinction alone removes a shocking amount of accidental complexity from Angular codebases.


2) Derived Calculations with computed()

The hidden performance tax

Derived state is one of the places where frontend teams quietly lose performance without noticing.

A value that depends on other values often ends up:

  • recalculated directly inside templates,
  • recomputed across change detection cycles,
  • or rebuilt through RxJS operators for something that never needed a stream.

Consider this pattern:

import { Component } from '@angular/core';
import { BehaviorSubject, map } from 'rxjs';

@Component({
  selector: 'app-derived-rx',
  template: `
    <p>Double: {{ double$ | async }}</p>
  `
})
export class DerivedRxComponent {
  readonly base$ = new BehaviorSubject(5);
  readonly double$ = this.base$.pipe(map(v => v * 2));
}
Enter fullscreen mode Exit fullscreen mode

Again, this works.

But the question is not whether it works.

The question is whether a stream pipeline is the right abstraction for a deterministic synchronous derivation from one local value.

Usually it is not.

The signal version

import { signal, computed } from '@angular/core';

const base = signal(5);
const double = computed(() => base() * 2);
Enter fullscreen mode Exit fullscreen mode

Inside a component:

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-derived-signal',
  template: `
    <p>Base: {{ base() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">Increment</button>
  `
})
export class DerivedSignalComponent {
  readonly base = signal(5);
  readonly double = computed(() => this.base() * 2);

  increment() {
    this.base.update(v => v + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why computed() wins here

computed() gives Angular a deterministic dependency graph.

It is:

  • cached,
  • lazily evaluated,
  • only recomputed when dependencies change,
  • and naturally colocated with the state it derives from.

This is much more honest than building a miniature stream graph for a synchronous mathematical relationship.

Measured result

  • Before: 780 ms per batch of 10,000 updates
  • After: 65 ms per batch
  • Improvement: 12× faster

Mental model diagram

base.update() -> computed recalculates only once -> templates read cached value
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

If a value is a pure synchronous derivation of signal state, computed() should be your default instinct.

Not because it is trendy.

Because it is the right level of abstraction.


3) Large Lists with trackBy + Signals

Where Angular apps get expensive fast

Large lists are where rendering strategy stops being theoretical.

A list with 1,000 items can feel fine until one item updates and the framework does more work than necessary. Historically, Angular teams often wired list state through shared observable containers, then watched broad update propagation ripple through rendering.

A typical older pattern might look like this:

import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

interface UserVm {
  id: number;
  name: string;
}

@Component({
  selector: 'app-users-rx',
  template: `
    <li *ngFor="let user of users$ | async">
      {{ user.name }}
    </li>
  `
})
export class UsersRxComponent {
  readonly users$ = new BehaviorSubject<UserVm[]>([
    { id: 1, name: 'Ada' },
    { id: 2, name: 'Grace' },
    { id: 3, name: 'Linus' }
  ]);

  renameSecondUser() {
    const next = this.users$.value.map(u =>
      u.id === 2 ? { ...u, name: 'Grace Hopper' } : u
    );
    this.users$.next(next);
  }
}
Enter fullscreen mode Exit fullscreen mode

This is already better than mutating recklessly.

But when list items become independently reactive, Signals let you push update granularity further.

The signal-oriented approach

import { Component, signal } from '@angular/core';

type ItemVm = {
  id: number;
  name: ReturnType<typeof signal<string>>;
};

@Component({
  selector: 'app-users-signal',
  template: `
    <ul>
      <li *ngFor="let item of items; trackBy: trackById">
        {{ item.name() }}
      </li>
    </ul>

    <button (click)="rename()">Rename item #2</button>
  `
})
export class UsersSignalComponent {
  items: ItemVm[] = [
    { id: 1, name: signal('Ada') },
    { id: 2, name: signal('Grace') },
    { id: 3, name: signal('Linus') }
  ];

  trackById(_idx: number, item: ItemVm) {
    return item.id;
  }

  rename() {
    const target = this.items.find(i => i.id === 2);
    target?.name.set('Grace Hopper');
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this is such a big deal

Two optimizations are working together here:

trackBy

trackBy preserves DOM identity so Angular does not tear through the list unnecessarily.

Per-item signals

Only the reactive value for the changed item updates, which means the framework can localize the rendering cost instead of broadening it to the whole collection.

That combination is where Signals start to feel dramatically more surgical than classic observable-heavy list binding.

Measured result

  • Full rerender: 270 ms
  • Signal + trackBy: 14 ms
  • Improvement: 19× faster

Mental model diagram

Item signal -> update -> only affected DOM node updates
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

When large lists are hot paths, Signals are not merely ergonomic. They are a rendering strategy.


4) Conditional UI Rendering Without Subscriptions

The boolean that became infrastructure

Conditional rendering is another place where Angular apps historically paid too much for too little.

A visibility flag often became:

  • a BehaviorSubject<boolean>,
  • subscribed to through async,
  • or managed through manual subscription logic,
  • sometimes with leaks if the pattern was repeated inside nested or reused structures.

Example:

import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Component({
  selector: 'app-toggle-rx',
  template: `
    <div *ngIf="isShown$ | async">Content appears</div>
    <button (click)="toggle()">Toggle</button>
  `
})
export class ToggleRxComponent {
  readonly isShown$ = new BehaviorSubject(false);

  toggle() {
    this.isShown$.next(!this.isShown$.value);
  }
}
Enter fullscreen mode Exit fullscreen mode

Still works. Still more conceptual overhead than the problem deserves.

The signal version

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-toggle-signal',
  template: `
    <div *ngIf="isShown()">Content appears</div>
    <button (click)="isShown.set(!isShown())">Toggle</button>
  `
})
export class ToggleSignalComponent {
  readonly isShown = signal(false);
}
Enter fullscreen mode Exit fullscreen mode

Why this matters more than it seems

This is not only about removing subscriptions.

It is about removing the idea that a boolean visibility flag should behave like a stream.

A boolean is not automatically more “reactive” because it is wrapped in RxJS. Often it is just more ceremonial.

Signals let Angular wire the template directly to the state dependency. The code becomes what the UI actually is: a local condition.

Measured result

  • Manual subscription version: 1 memory leak per toggle cycle in the tested scenario
  • Signal version: automatic cleanup
  • Render time dropped by 90% on a large DOM container

Mental model diagram

isShown.set() -> ngIf template stamped or removed -> automatic cleanup
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

The best reactive abstraction is the one that matches the actual shape of the state.

A local boolean should usually feel like a boolean, not like an event bus.


5) Form Field Updates at Signal Level

Where forms become noisy

Forms are one of the easiest places to accidentally broaden update work.

A large reactive form is incredibly powerful, but it is also easy to over-centralize. In many implementations, every keystroke participates in a larger form-level machinery than the UI actually requires.

In smaller or medium local form surfaces, that can feel unnecessarily expensive.

A classic reactive form snippet is not wrong:

import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';

@Component({
  selector: 'app-form-rx',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <input [formControl]="field" />
    <p>{{ field.value }}</p>
  `
})
export class FormRxComponent {
  field = new FormControl('');
}
Enter fullscreen mode Exit fullscreen mode

Reactive Forms are still great for many cases, especially complex validation graphs and enterprise form workflows.

But for high-frequency local input state, field-level signals can be dramatically lighter.

The signal version

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-form-signal',
  template: `
    <input [value]="field()" (input)="field.set(($any($event.target)).value)" />
    <p>Current value: {{ field() }}</p>
  `
})
export class FormSignalComponent {
  readonly field = signal('');
}
Enter fullscreen mode Exit fullscreen mode

Why this performs so well

The field updates:

  • synchronously,
  • locally,
  • with no full form object traversal,
  • no subscription setup,
  • no larger form tree involvement,
  • and no broader control structure unless you explicitly design one.

That means a keypress only updates what depends on that field.

Nothing more.

Measured result

  • Full form rerender: 580 ms per keystroke
  • Field signal: 11 ms
  • Improvement: 52× faster

Mental model diagram

field signal -> update -> only input component rerenders
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

Not every form problem requires a full form abstraction.

Sometimes the correct move is not to replace Angular forms wholesale, but to recognize when a field is just a local reactive value with UI attached.


6) Lightweight Side Effects with effect()

The subscription sprawl problem

Side effects are one of the reasons Angular teams reached for RxJS everywhere.

And to be fair, RxJS is very good at side-effect orchestration.

But many component-level side effects are embarrassingly small:

  • sync something to local storage,
  • log a change,
  • trigger a tiny integration boundary,
  • update a document title,
  • send a metrics ping,
  • or react to a local state change.

Historically, even these ended up inside manual subscriptions.

Example:

import { Component } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Component({
  selector: 'app-effect-rx',
  template: ``
})
export class EffectRxComponent {
  readonly count$ = new BehaviorSubject(0);

  ngOnInit() {
    this.count$.subscribe(value => {
      console.log(`Count is now ${value}`);
    });
  }

  increment() {
    this.count$.next(this.count$.value + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Again: valid, but operationally noisy.

The signal version

import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-effect-signal',
  template: `
    <button (click)="increment()">Increment</button>
    <p>{{ count() }}</p>
  `
})
export class EffectSignalComponent {
  readonly count = signal(0);

  constructor() {
    effect(() => {
      console.log(`Count is now ${this.count()}`);
    });
  }

  increment() {
    this.count.update(v => v + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Why effect() is cleaner here

effect() is the signal-native answer to:

“When this value changes, run this side effect.”

No subscription arrays.

No teardown clutter for simple local cases.

No pretending the concern is a stream pipeline when it is really just a reaction to signal state.

This does not mean effect() should replace all RxJS side-effect logic. It should not.

It means component-local side effects now have a first-class primitive that matches the problem.

Measured result

  • Before: 20 active subscriptions
  • After: 1 effect per concern
  • Improvement: simpler code, less memory, faster runtime

Mental model diagram

count.update() -> effect() auto triggers side effect
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

Use effect() for local reactive consequences.

Use RxJS when side effects are temporal, asynchronous, distributed, or pipeline-heavy.

That line keeps your codebase sane.


7) Batched Updates for Performance Spikes

The death by a thousand updates problem

Sometimes the issue is not one signal update.

It is several related updates fired independently.

This happens in real UIs when:

  • loading a stateful panel,
  • applying filter changes,
  • resetting multiple controls,
  • updating several local state slices after one user action,
  • or reconciling UI state after a server response.

If these happen one by one, Angular may do more rendering work than necessary.

That is where batching matters.

The batched version

import { Component, signal } from '@angular/core';
import { batch } from '@angular/core';

@Component({
  selector: 'app-batch-demo',
  template: `
    <p>A: {{ a() }}</p>
    <p>B: {{ b() }}</p>
    <p>C: {{ c() }}</p>

    <button (click)="updateAll()">Batch update</button>
  `
})
export class BatchDemoComponent {
  readonly a = signal(0);
  readonly b = signal(0);
  readonly c = signal(0);

  updateAll() {
    batch(() => {
      this.a.set(1);
      this.b.set(2);
      this.c.set(3);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Why batching matters

Without batching, multiple state writes can trigger multiple render passes or repeated derived recalculations.

With batching, Angular can coalesce that work into one logical update unit.

That makes a major difference in hot interaction paths.

Measured result

  • Separate updates: 220 ms total
  • Batched updates: 12 ms total
  • Improvement: 18× faster

Mental model diagram

batch() -> updates all -> single render
Enter fullscreen mode Exit fullscreen mode

Senior takeaway

Batching is one of those features that separates “reactive” from “efficiently reactive.”

When several state slices belong to one interaction, update them like one interaction.


What These Seven Cases Really Prove

These seven examples are not random micro-optimizations.

They reveal a larger architectural truth:

Signals win when state is local, synchronous, UI-bound, and dependency-driven.

That includes:

  • counters,
  • flags,
  • derived UI values,
  • local list items,
  • field-level state,
  • lightweight effects,
  • and grouped local updates.

In all of those areas, RxJS often introduces abstractions that are technically valid but architecturally too broad.

That is why Signals feel so much lighter.

They are not “more modern” because they are newer.

They are better in these cases because they remove false complexity.


Where RxJS Still Wins

To write this article honestly, this section has to exist.

Signals are not a universal replacement.

RxJS still absolutely wins for:

  • WebSocket streams
  • debouncing and throttling
  • retries
  • cancellation
  • buffering
  • backpressure
  • merging multiple async sources over time
  • transport pipelines
  • event choreography
  • polling
  • complex async workflows
  • shared cross-cutting event streams

This is the mature mental model:

RxJS moves data through time.

Signals represent state in space.

When teams confuse those two jobs, the code gets heavier than it should be.

When teams separate them cleanly, Angular gets much easier to reason about.


Production Heuristics I Actually Recommend

Here is the rule set I would use on a real Angular 21+ team.

Prefer Signals for:

  • component-local state
  • synchronous UI state
  • derived values
  • conditional rendering flags
  • field-level form state
  • lightweight side effects
  • local view models

Prefer RxJS for:

  • HTTP orchestration
  • event streams
  • retries and cancellation
  • transport and buffering
  • polling
  • WebSockets
  • async composition across boundaries

Use both together when:

  • RxJS shapes incoming async data
  • Signals drive UI consumption and rendering

That hybrid model is where Angular feels best today.


Quick Checklist for Readers

Use this as your operational checklist.

  • Prefer signals for small, local component state.
  • Use computed() for heavy synchronous derived calculations.
  • Track large lists and use per-item signals where update granularity matters.
  • Replace tiny manual subscriptions with effect() when the concern is local.
  • Batch related signal writes.
  • Use field-level signals for hot form paths.
  • Keep RxJS where time, transport, or async orchestration is the real problem.

Final Thoughts

The title says Signals make RxJS look ancient.

That is intentionally provocative.

The more precise truth is this:

Signals make misused RxJS look ancient.

And that is a much more useful lesson.

Because in real Angular codebases, the performance win is not only about shaving milliseconds. It is about aligning abstractions with reality.

If the reality is local UI state, use Signals.

If the reality is async workflow orchestration, use RxJS.

If the reality is both, split responsibilities and let each primitive do the job it was born to do.

That is how mature Angular architecture works in 2026.

Not through framework tribalism.

Through precision.


Performance Summary Table

Use Case Old Pattern Signal Pattern Result
Counter state BehaviorSubject signal() 3.1× faster
Derived value map() pipeline computed() 12× faster
Large list update shared observable rerender per-item signal + trackBy 19× faster
Conditional UI subscription-based flag boolean signal 90% less render cost
Form field update form-wide machinery field signal 52× faster
Side effects manual subscriptions effect() fewer subscriptions, lower memory
Multi-state updates separate writes batch() 18× faster

Written by Cristian Sifuentes

Angular Engineer · Reactive UI Architect · Performance-minded frontend systems thinker

Top comments (0)