Angular Signal vs Observable: The 2026 Decision Tree for Reactive Primitives [2026]

Link copied
Angular Signal vs Observable: The 2026 Decision Tree for Reactive Primitives [2026]

Angular Signal vs Observable: The 2026 Decision Tree for Reactive Primitives [2026]

Angular Tutorial Module 3: Signals Lesson 3.7

Angular gives you two reactive primitives: signals (since v17, baked into the framework) and RxJS Observables (since v2, the historical default). They overlap, they coexist, and the 2026 question is no longer "which one" — it's "which one for which job." This lesson is the decision tree.

This is lesson 3.7, following lesson 3.6 on zoneless internals. It is the bridge from Module 3 (signals) into Module 7 (RxJS in modern Angular). After this lesson you should never wonder which primitive to reach for.

The summary table #

Need Signal Observable
Holds a current value ⚠️ (use BehaviorSubject)
Notifies on change ✅ automatic ✅ explicit subscribe
Derived state computed() ⚠️ via combineLatest/map
Side effects on change effect() subscribe()
Time-based emissions (interval, timeout) ⚠️ via effect() + setInterval ✅ native (interval(), timer())
Cancellable async work ⚠️ manual or via resource() ✅ native (switchMap, takeUntil)
Hot vs cold semantics Always hot Cold by default
Multi-value over time ❌ holds only current ✅ stream of values
Memory of past values ✅ via operators (scan, buffer)
Backpressure / throttling debounced() / throttled() debounceTime, throttleTime
Template integration ✅ auto-tracked, no pipe ✅ via async pipe
Test ergonomics ✅ simple — set + read ⚠️ marble testing, mocks

Use the table as a quick lookup. The rest of the lesson explains the meaningful rows.

The decision tree #

Start at the top. The first "yes" branch wins.

1. Do I hold a value that components render directly?
   ├── YES → SIGNAL
   └── NO → continue

2. Is this a STREAM of values over time (events, ticks, messages)?
   ├── YES → OBSERVABLE
   └── NO → continue

3. Is this an async operation (HTTP, fetch) with cancellation semantics?
   ├── YES → resource() / httpResource() (signal-shaped, RxJS-backed)
   └── NO → continue

4. Does this come from RxJS code I cannot easily change?
   ├── YES → keep as Observable, bridge to signal at the boundary with toSignal()
   └── NO → SIGNAL

In practice: signals win for app state, RxJS wins for streams and complex async pipelines, resource() wins for HTTP. That covers ~95% of cases.

When signals are the right answer #

Component state. Almost every field on a component is naturally a signal in 2026:

export class CartComponent {
  items = signal<Item[]>([]);          // current cart
  loading = signal(false);              // UI state
  total = computed(() => this.items().reduce((s, i) => s + i.price, 0));
}

The template auto-tracks each read. Updates fire CD only on the views that read them. No unsubscribe, no async pipe.

Cross-component shared state. A service exposes signals; multiple components read them:

@Injectable({ providedIn: 'root' })
export class AuthService {
  user = signal<User | null>(null);
  isLoggedIn = computed(() => this.user() !== null);
}

// Any component
user = inject(AuthService).user;

No Subject, no BehaviorSubject. The signal IS the state.

Derived state. computed() is purpose-built for this:

fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
activeItems = computed(() => this.items().filter(i => i.active));

Memoized, lazy, type-safe. No combineLatest + map chain.

When Observables are the right answer #

Streams of events over time. Mouse moves, scroll, WebSocket messages, server-sent events — anything with a temporal dimension where the SEQUENCE of values matters:

const scroll$ = fromEvent(window, 'scroll').pipe(
  map(() => window.scrollY),
  throttleTime(50),
);

const messages$ = webSocket('wss://api.example.com').pipe(
  retry({ delay: 1000 }),
  share(),
);

Signals only hold the CURRENT value. If you need to react to every message, run a scan over them, or compose them with combineLatest, RxJS is the right tool.

Complex async orchestration. Cancellable, race-conditioned, retryable HTTP-like flows:

searchResults$ = this.query$.pipe(
  debounceTime(300),
  switchMap(q => this.http.get(`/api/search?q=${q}`).pipe(
    retry({ count: 2, delay: 500 }),
    catchError(() => of([])),
  )),
);

The switchMap cancellation alone is non-trivial to replicate with signals + effects. RxJS owns this pattern. For HTTP specifically, prefer httpResource() (lesson 6.3) which wraps the pattern in a signal-shaped API.

Operators you actually need. withLatestFrom, pairwise, bufferTime, groupBy, partition, mergeAll — each does a specific thing that would take many lines of signal/effect code. If you find yourself reaching for any of them, an Observable is the right shape.

When resource() is the right answer #

The resource() family (lesson 6.3) is a signal-shaped wrapper around async work:

const userId = signal<string>('alice');

const user = resource({
  params: () => ({ id: userId() }),
  loader: ({ params, abortSignal }) =>
    fetch(`/api/users/${params.id}`, { signal: abortSignal }).then(r => r.json()),
});

// In template
@if (user.isLoading()) { <p>Loading...</p> }
@else if (user.error()) { <p>Error: {{ user.error()!.message }}</p> }
@else { <p>Hello, {{ user.value()!.name }}</p> }

Three advantages over raw signal + effect:

  1. Cancellation — when userId changes, the previous in-flight request is aborted automatically via AbortController
  2. Loading + error states built in — no manual isLoading = signal(false) plumbing
  3. Signal-shaped output — templates consume it natively, no async pipe

Use resource() for any async-with-cancellation scenario. Use httpResource() specifically for HTTP — it wraps HttpClient and adds caching, headers, error handling.

Bridging the two #

Three bridge functions in @angular/core/rxjs-interop:

toSignal(observable$) — Observable → Signal #

import { toSignal } from '@angular/core/rxjs-interop';

export class UserComponent {
  user = toSignal(this.http.get<User>('/api/user'));
  // user() is User | undefined
}

Useful when you have an existing Observable (HTTP, route params, library API) and want signal-shaped consumption. The signal mirrors the observable's latest value; subscription is auto-cleaned on component destroy.

toObservable(signal) — Signal → Observable #

import { toObservable } from '@angular/core/rxjs-interop';

export class SearchComponent {
  query = signal('');

  results$ = toObservable(this.query).pipe(
    debounceTime(300),
    switchMap(q => this.http.get(`/api/search?q=${q}`)),
  );
}

Useful when you need RxJS operators (debounceTime, switchMap) on a signal-driven input. Bridges the signal into the Observable world; the rest of the pipe runs natively.

outputFromObservable(observable$) — Observable → component output #

import { outputFromObservable } from '@angular/core/rxjs-interop';

export class MouseTrackerComponent {
  pointerMoved = outputFromObservable(
    fromEvent<PointerEvent>(window, 'pointermove').pipe(throttleTime(50)),
  );
}

When your output emissions originate in a stream, this is cleaner than manual subscribe + emit.

Comparing common patterns side-by-side #

Pattern: counter #

// Signal — 1 line
count = signal(0);
inc() { this.count.update(n => n + 1); }

// Observable — 4 lines + an async pipe
count$ = new BehaviorSubject(0);
inc() { this.count$.next(this.count$.getValue() + 1); }
// template: {{ count$ | async }}

Signal wins on clarity and brevity.

// Signal (v22)
query = signal('');
debouncedQuery = debounced(this.query, 300);
results = httpResource(() => ({ url: '/api/search', params: { q: this.debouncedQuery() } }));

// Observable
query$ = new BehaviorSubject('');
results$ = this.query$.pipe(
  debounceTime(300),
  switchMap(q => this.http.get(`/api/search?q=${q}`)),
);

Tied. The signal version is shorter and easier to read; the Observable version has better cancellation guarantees on slower networks. In practice both work fine.

Pattern: WebSocket message stream #

// Observable — natural fit
messages$ = webSocket('wss://api/feed').pipe(
  scan((acc: Msg[], m: Msg) => [...acc, m], []),
  shareReplay(1),
);

// Signal — awkward
messages = signal<Msg[]>([]);
constructor() {
  const ws = new WebSocket('wss://api/feed');
  ws.onmessage = e => this.messages.update(m => [...m, JSON.parse(e.data)]);
  inject(DestroyRef).onDestroy(() => ws.close());
}

Observable wins. The scan operator captures the "accumulate over time" semantic the signal version awkwardly reimplements.

Coexisting in one component #

Mixed-primitive components are common:

export class DashboardComponent {
  // Signal-shaped state
  filter = signal<'all' | 'active' | 'archived'>('all');

  // RxJS for the stream
  refresh$ = new Subject<void>();

  // Bridge signal in, RxJS for fetching, signal out
  items = toSignal(
    combineLatest([toObservable(this.filter), this.refresh$.pipe(startWith(null))]).pipe(
      switchMap(([f]) => this.http.get<Item[]>(`/api/items?filter=${f}`)),
    ),
    { initialValue: [] },
  );

  refresh() { this.refresh$.next(); }
}

The state is a signal (for templates). The fetch pipeline is RxJS (for cancellation + the manual refresh trigger). The two bridge cleanly with toObservable and toSignal. This pattern — signals for state, RxJS for transitions — covers most non-trivial cases.

Migration: should I rewrite my RxJS code? #

No, mostly. The framework supports both forever. Rewrite when:

  • A component's reactive logic is mostly state holding + simple derivation → signals are simpler
  • You're already touching the file for unrelated changes → refactor opportunistically
  • A new feature requires the change → don't rewrite a whole codebase to add one feature

Do NOT rewrite when:

  • The code uses complex operators (mergeMap, withLatestFrom, groupBy)
  • The tests are extensive and would all need updating
  • It works

Common gotchas #

Symptom Cause Fix
toSignal() returns undefined until first emit Default behavior Pass { initialValue: ... } or { requireSync: true }
toObservable() doesn't fire on first read It's HOT — only emits subsequent changes Use pipe(startWith(signal())) for an initial emission
Subscription leak with mixed code subscribe() inside an effect/computed Use toSignal() or pipe through takeUntilDestroyed()
Signal updates lag observable emits RxJS chain has a buffering operator Audit operators — auditTime, bufferTime, sampleTime all delay
Two-way data flow becomes a mess Mixing model() and BehaviorSubject for the same data Pick one shape per piece of state

What's next #

Lesson 3.8 closes Module 3 with linkedSignal() — the primitive for writable derived state. Module 4 picks up forms (signal forms, reactive, template-driven). Module 7 returns to RxJS in detail — when it is still the right answer and how to wire it cleanly.

Try it yourself #

A component that uses signals for state, RxJS for fetching:

import { Component, signal, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-todos',
  template: `
    @if (todos()) {
      <ul>@for (t of todos()!; track t.id) { <li>{{ t.title }}</li> }</ul>
    } @else {
      <p>Loading...</p>
    }
  `,
})
export class TodosComponent {
  private http = inject(HttpClient);
  // RxJS for the fetch, signal at the component boundary
  todos = toSignal(this.http.get<{ id: number; title: string }[]>('/api/todos'));
}

One component, two primitives, each in its zone of strength. No async pipe in the template, no manual subscribe in the class.

YouShould I default to signals for new code and only use RxJS when I have to?
Claude · used get_best_practicesYes. The 2026 Angular team’s recommendation is signal-first — reach for RxJS only when you genuinely need stream semantics (event sequences, complex async orchestration, operators like switchMap/withLatestFrom). For everything that smells like “state” or “derived state,” signals are the right tool. HTTP specifically lives in httpResource() these days, which gives you signal-shaped output with RxJS-backed cancellation under the hood. Best of both.

Lesson 3.8 closes Module 3 with linkedSignal() — for state that is BOTH derived AND writable.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *