Angular Signal vs Observable: The 2026 Decision Tree for Reactive Primitives [2026]
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:
- Cancellation — when
userIdchanges, the previous in-flight request is aborted automatically via AbortController - Loading + error states built in — no manual
isLoading = signal(false)plumbing - Signal-shaped output — templates consume it natively, no
asyncpipe
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.
Pattern: debounced search #
// 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.
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
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


