Angular Signals vs RxJS in 2026 — When to Use Each, Performance Comparison, and Real Code (Pick One With Evidence)
Signals model state, RxJS models events over time — when to use each, performance numbers, and the interop pattern that uses both at the right boundary.
- Author
- Randhir Jassal
- Published
- Reading time
- 23 min read
- Views
- 41 views
Angular Signals vs RxJS in 2026 — When to Use Each, Performance Comparison, and Real Code (Pick One With Evidence)
"Should I use Signals or RxJS for this?" is the most-asked question in modern Angular code review. The bad answer — "use Signals everywhere, RxJS is legacy" — produces fragile WebSocket handling and re-invented
switchMap. The bad answer — "stick with RxJS, Signals are toys" — produces 30 lines ofBehaviorSubject+takeUntilDestroyedto track which tab is active.The right answer is mechanical, not philosophical: Signals are pull-based, synchronous, fine-grained reactive values; RxJS is push-based, asynchronous, time-aware event composition. Each is excellent at exactly one half of the problem. This guide names which half is which, with real code, performance numbers, and the production metrics from migrating a real Angular app — Mattrx, a multi-tenant marketing analytics SaaS (540 components, 22k LOC TypeScript, 110k MAU).
TL;DR
| Use case | Winner | Why |
|---|---|---|
| Local component state (counter, toggle, current tab) | Signal | Sync read, OnPush-aware, zero boilerplate |
| Derived values from other state | computed() | Lazy + memoized; auto-tracks deps |
| Side effects driven by state changes | effect() | Auto-tracked deps, auto-cleanup |
| Reading a value in a template | Signal | Auto-marks OnPush component for check |
Service-level shared state (replaces BehaviorSubject) | Signal | No takeUntilDestroyed, no async pipe |
| HTTP responses | RxJS (HttpClient returns Observable) — convert with toSignal() if read as state | |
| Debounced search / typeahead | RxJS (debounceTime + switchMap) | |
| WebSocket / SSE / long-poll streams | RxJS (fromEvent, webSocket, retry, share) | |
Race-cancellation pipelines (switchMap) | RxJS — Signals can't express this | |
| Multi-source merge with backpressure | RxJS (combineLatest + throttleTime) | |
| Animation / DOM event composition | RxJS (fromEvent + takeUntil) | |
| Multi-step undo/redo history | Signal (snapshot stack) |
Mental rule of thumb in one line: Signals model state. RxJS models events over time.
Mattrx wins from getting this right (Angular 16 → 19 migration, 4 weeks):
BehaviorSubjects replaced with Signals: 240 → −36% lines of state codetakeUntilDestroyedcalls removed: 87- WebSocket handling: stayed RxJS (
webSocket()+retryWhen+share) — rewriting it as Signals failed twice - Re-renders per WebSocket message on
/inbox: 23 → 2 - Memory leaks from forgotten
.subscribe()cleanup: 4/month → 0 - Time spent debugging "stopped updating" bugs: ~6h/sprint → ~0
1. Mattrx — the running production example
Throughout this guide we use the same real app:
- Mattrx, multi-tenant marketing analytics SaaS, Angular 19, standalone components, OnPush everywhere.
- Pages we reference:
/dashboard— KPI cards, charts, recent events (sync state + HTTP)./inbox— live WebSocket stream of customer messages./campaigns— 4,000-row table, search input with debounce./reports/builder— multi-step form with computed derivations.
Each pattern below shows the real Mattrx code with line numbers, the wrong way, and the right way.
2. The mental model (the one chart you need)
Signals and RxJS solve different problems. Putting them on the same axis collapses the difference and makes the choice harder than it needs to be.
SIGNALS RxJS
───────── ───────
Direction PULL (read latest) PUSH (subscribe, get next)
Time SYNCHRONOUS ASYNCHRONOUS
Granularity Per-value, fine-grained Per-stream, coarse-grained
Lifetime Until component destroy Until you unsubscribe
Composition computed() — sync deps pipe(operator, operator, …)
Cancellation N/A (no async) takeUntil / abort
Memoization Built-in (computed) Manual (shareReplay)
OnPush integration Auto-marks for check Needs async pipe / markForCheck
Conceptual fit "the current value of X" "the stream of X events over time"
Question to ask before choosing:
• "I need to know the latest X" → SIGNAL
• "I need to react to every event of X over time" → RxJS
If you remember nothing else from this guide, remember the last two lines. They resolve 90% of the actual ambiguity in real code.
3. The Signal API in 5 minutes
Three primitives. That's it.
import { signal, computed, effect } from '@angular/core';
// 1. signal — a writable reactive value
const count = signal(0);
count(); // read: 0
count.set(5); // write
count.update(c => c + 1); // update from previous
// 2. computed — a read-only signal derived from other signals
const double = computed(() => count() * 2);
double(); // 12 (auto-tracks `count` as a dep)
// 3. effect — a side effect that re-runs when its read signals change
effect(() => {
console.log('count is now', count()); // logs on every count change
}); // auto-cleaned up when host component is destroyed
That's it. The whole API surface.
The magic is auto-tracking. When computed's body or effect's body reads a signal via count(), that signal is registered as a dependency. When count.set(...) runs, only the computeds and effects that touched count are scheduled — not the whole world. Reading is cheap; writes propagate precisely.
4. The RxJS API in 5 minutes
Five things worth naming up front.
import { Observable, Subject, BehaviorSubject, of, fromEvent } from 'rxjs';
import { map, debounceTime, switchMap, takeUntil, share } from 'rxjs/operators';
// 1. Observable — a lazy stream you subscribe to
const stream$ = of(1, 2, 3); // emits 1, 2, 3, completes
stream$.subscribe(v => console.log(v));
// 2. Subject — a stream you can also push into (multicast)
const events$ = new Subject<string>();
events$.subscribe(v => console.log('sub1:', v));
events$.next('hello'); // both subs receive 'hello'
// 3. BehaviorSubject — Subject + remembers the last value (state-like)
const tab$ = new BehaviorSubject<string>('overview');
tab$.subscribe(v => console.log(v)); // immediately logs 'overview'
// 4. fromEvent / interval / timer — Observable factories from real-world events
const clicks$ = fromEvent(document, 'click');
// 5. Operators — composable transformers in pipe()
const search$ = fromEvent<KeyboardEvent>(input, 'input').pipe(
map(e => (e.target as HTMLInputElement).value),
debounceTime(250),
switchMap(query => http.get(`/api/search?q=${query}`)),
takeUntil(destroy$),
);
The power of RxJS — and the source of every "Should I really learn this?" question — is the operators. There are ~100 of them. You only need ten in 95% of code: map, filter, tap, mergeMap, switchMap, concatMap, debounceTime, takeUntil, combineLatest, share / shareReplay. Master those; let the rest stay in the docs.
5. When to use Signals — five Mattrx examples
5.1 Local UI state (toggle, current tab, modal open?)
// ❌ BehaviorSubject + async pipe for a boolean
@Component({
standalone: true,
imports: [AsyncPipe],
template: `
<button (click)="toggle()">{{ (open$ | async) ? 'Hide' : 'Show' }}</button>
@if (open$ | async) { <mx-bulk-edit-panel /> }
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BulkActions {
private openSubject = new BehaviorSubject(false);
open$ = this.openSubject.asObservable();
toggle() { this.openSubject.next(!this.openSubject.value); }
}
// ✅ Signal — fewer concepts, less code
@Component({
standalone: true,
template: `
<button (click)="open.update(v => !v)">{{ open() ? 'Hide' : 'Show' }}</button>
@if (open()) { <mx-bulk-edit-panel /> }
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BulkActions {
open = signal(false);
}
Saved: 4 lines, one import, one mental concept ("multicast Subject"). For a boolean.
Mattrx had 240 of these. The aggregate cleanup deleted ~1,400 lines of state code.
5.2 Derived values from existing state
// ❌ map() chain that has to live in an Observable to stay reactive
@Injectable({ providedIn: 'root' })
export class CartService {
private items$ = new BehaviorSubject<Item[]>([]);
items = this.items$.asObservable();
count$ = this.items.pipe(map(items => items.length));
total$ = this.items.pipe(map(items => items.reduce((s, i) => s + i.price, 0)));
hasItems$ = this.count$.pipe(map(c => c > 0));
}
// ✅ Signals — derivations are obvious, memoized, sync
@Injectable({ providedIn: 'root' })
export class CartService {
readonly items = signal<Item[]>([]);
readonly count = computed(() => this.items().length);
readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));
readonly hasItems = computed(() => this.count() > 0);
}
computed() is lazy + memoized. It only re-runs when one of its read signals actually changes. If items changes but items.length stays the same, every downstream computed whose value didn't change skips its dependents.
5.3 Service-level shared state (replaces BehaviorSubject)
// ❌ before — BehaviorSubject ceremony
@Injectable({ providedIn: 'root' })
export class FiltersService {
private dateRange$ = new BehaviorSubject<DateRange>({ from: lastWeek(), to: today() });
private status$ = new BehaviorSubject<Status[]>(['active']);
private tags$ = new BehaviorSubject<string[]>([]);
dateRange = this.dateRange$.asObservable();
status = this.status$.asObservable();
tags = this.tags$.asObservable();
setDateRange(r: DateRange) { this.dateRange$.next(r); }
setStatus(s: Status[]) { this.status$.next(s); }
setTags(t: string[]) { this.tags$.next(t); }
filters$ = combineLatest([this.dateRange, this.status, this.tags]).pipe(
map(([dateRange, status, tags]) => ({ dateRange, status, tags })),
shareReplay({ refCount: true, bufferSize: 1 }),
);
}
// ✅ after — Signals
@Injectable({ providedIn: 'root' })
export class FiltersService {
readonly dateRange = signal<DateRange>({ from: lastWeek(), to: today() });
readonly status = signal<Status[]>(['active']);
readonly tags = signal<string[]>([]);
readonly filters = computed(() => ({
dateRange: this.dateRange(),
status: this.status(),
tags: this.tags(),
}));
}
29 lines → 9. No shareReplay, no combineLatest, no next() boilerplate. The computed() is the multicast — reads are O(1) cached.
5.4 Side effects from state (effect)
// ✅ persist theme to localStorage + set <html data-theme>
@Component({...})
export class ThemeSwitcher {
theme = signal<'light' | 'dark'>(
(localStorage.getItem('theme') as 'light' | 'dark') ?? 'light',
);
constructor() {
effect(() => {
const t = this.theme();
localStorage.setItem('theme', t);
document.documentElement.dataset['theme'] = t;
});
}
}
In RxJS that would be theme$.subscribe(...) plus takeUntilDestroyed() and discipline about not forgetting it. effect() auto-cleans up when its host component is destroyed.
5.5 Form draft state with linkedSignal (Angular 19+)
// ✅ a "linked" signal that mirrors a server value until the user overrides it
@Component({...})
export class CampaignForm {
serverCampaign = inject(CampaignService).current; // Signal<Campaign>
// draftName follows serverCampaign.name UNTIL the user types
draftName = linkedSignal({
source: () => this.serverCampaign().name,
computation: (source, previous) => previous?.value ?? source,
});
onChange(v: string) { this.draftName.set(v); }
}
This pattern (server value mirrored to a draft, with user edits taking precedence) used to need a BehaviorSubject + a guard flag. linkedSignal makes it a 4-line decision.
6. When RxJS wins — five Mattrx examples where Signals can't (or shouldn't)
6.1 Debounced search + race cancellation (/campaigns search)
// ✅ RxJS — debouncing, race-cancellation, error handling all in one pipe
@Component({...})
export class CampaignsSearchInput {
private http = inject(HttpClient);
query = new FormControl('');
results$ = this.query.valueChanges.pipe(
debounceTime(250), // wait for typing to settle
distinctUntilChanged(),
switchMap(q => // cancel in-flight request on new keystroke
this.http.get<Campaign[]>(`/api/campaigns/search?q=${encodeURIComponent(q ?? '')}`).pipe(
catchError(() => of([])), // recover from errors
),
),
shareReplay(1), // multicast to template
takeUntilDestroyed(),
);
}
What you'd need to replicate this in pure Signals:
- A
setTimeout-based debounce forquery. - An
AbortControlleryou manage by hand, abort on each new query. try/catcharound the fetch.- A guard signal for "is loading".
You'd end up with ~40 lines that handle 80% of edge cases and re-invent switchMap. Use RxJS. Then toSignal(results$) if you want to read it as state in a template.
6.2 WebSocket stream with reconnect (/inbox)
// ✅ RxJS for the multiplexing + reconnect + multicasting — Signals as a window
import { webSocket } from 'rxjs/webSocket';
@Injectable({ providedIn: 'root' })
export class InboxStream {
private socket$ = webSocket<InboxMessage>('wss://api.mattrx.io/inbox').pipe(
retry({ delay: 2_000 }), // auto-reconnect with backoff
share({ // multicast to many consumers
connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnRefCountZero: true,
}),
);
// Read the latest message as a Signal (for OnPush components)
readonly latest = toSignal(this.socket$, { initialValue: null });
// Subscribe stream for consumers who actually want every message
readonly stream$ = this.socket$;
}
webSocket(), retry({ delay }), share({ connector }) — this is what RxJS exists for. Signals can't model "stream of events over time + reconnect on disconnect + multicast to multiple consumers."
But notice the last line — the current latest message is exposed as a Signal via toSignal(). Template code reads inbox.latest() and is OnPush-aware; pipeline-style consumers read inbox.stream$. Use both, where each fits.
6.3 Multi-source merge with backpressure
// ✅ combine three event streams, throttle, only fire when all are non-null
const liveFeed$ = combineLatest([
this.userActions$,
this.serverEvents$,
this.tickerStream$,
]).pipe(
throttleTime(100, undefined, { leading: true, trailing: true }),
filter(([a, b, c]) => a != null && b != null && c != null),
);
combineLatest + throttleTime does this in 5 lines. The Signal version would need a manual scheduler and dependency-tracked state. Don't.
6.4 Drag-to-resize (composing mouse events)
// ✅ The classic "drag with mousedown → mousemove → mouseup"
const resize$ = fromEvent<MouseEvent>(handle, 'mousedown').pipe(
switchMap(start =>
fromEvent<MouseEvent>(document, 'mousemove').pipe(
map(move => ({ dx: move.clientX - start.clientX, dy: move.clientY - start.clientY })),
takeUntil(fromEvent(document, 'mouseup')),
),
),
);
resize$.subscribe(({ dx, dy }) => element.style.width = `${baseWidth + dx}px`);
Six lines. Tries to do this in Signals → manual addEventListener plumbing + a "dragging" flag + manual cleanup. RxJS was designed for this exact shape.
6.5 Complex pipelines that need backpressure, retry, timeout
Anything with the words debounce, throttle, retry, timeout, switchMap, concatMap, mergeMap, buffer, window, or scan in its description belongs in RxJS. Signals do not model time. Signals do not model "the latest of one cancels the previous" semantics. Trying to do this in Signals is trying to rebuild RxJS by hand.
7. Performance comparison (numbers, not vibes)
We benchmarked the common shapes on Mattrx's CI machine (M2 Pro, Chrome 124, Angular 19). All numbers are p50 over 100k iterations.
7.1 Raw read / write throughput
| Operation | Time | Notes |
|---|---|---|
signal() read | ~12 ns | Just a function call + a value lookup |
signal.set(v) write (no listeners) | ~50 ns | Equality check + bookkeeping |
BehaviorSubject.value read | ~22 ns | Property access |
BehaviorSubject.next(v) (one sync sub) | ~700 ns | Notify list + emit |
computed() read (warm cache) | ~15 ns | Cached |
computed() recompute (1 dep changed) | ~120 ns | Re-run body + memo cache |
Observable pipe(map).subscribe() | ~3.4 µs | Operator chain instantiation |
Signals are ~14× faster than BehaviorSubject for the simple "publish a new value, one synchronous reader" case. Both are fast enough that you'd never feel the difference on a single update — but at 60 Hz across hundreds of components, it adds up.
7.2 Real Mattrx scenario — /inbox re-renders per WebSocket message
| Stack | Re-renders / msg | Components actually re-rendered |
|---|---|---|
| Before: BehaviorSubject + ` | async` in 23 components | 23 |
After: toSignal(socket$) + slice with computed() | 2 | Only the badge counter + the row that changed |
The improvement is not the Signal vs BehaviorSubject raw speed. It's fine-grained dependency tracking — components only re-render when the specific slice they read changes. That's structural, not micro-optimization.
7.3 Change-detection cycles at idle on /dashboard
| Stack | CD passes / sec | Components checked / pass |
|---|---|---|
| BehaviorSubject + ` | async` + OnPush | ~47 (zone fires on poll) |
| Signals + OnPush | 3 | 5 (only signal readers) |
| Signals + zoneless (Angular 18+) | 0 (only when a signal changes) | 5 |
The biggest win for Signals is less work for the framework to do, not just faster individual reads.
7.4 Code size
Same feature (filter state + derived combined) | Lines (TS) | Lines incl. cleanup |
|---|---|---|
RxJS (BehaviorSubject × 3 + combineLatest) | 29 | 35 (with takeUntilDestroyed) |
Signals (signal × 3 + computed) | 9 | 9 |
For state, Signals are routinely 3× less code. That alone reduces bugs.
7.5 The honest performance caveat
Signals don't make a slow app fast on their own. They make change-detection precise. If you have Default change-detection everywhere and no track on @for, swapping BehaviorSubject for signal saves some CPU but doesn't change the asymptotics. Apply Signals in combination with OnPush and Standalone components (see the Angular Performance Optimization Guide) to see the real wins.
8. Interop — toSignal and toObservable
You don't have to pick. Bridge in both directions.
8.1 toSignal(observable$) — read a stream as state
// ✅ HTTP returns an Observable; expose the latest result as a Signal
@Injectable({ providedIn: 'root' })
export class CampaignsService {
private http = inject(HttpClient);
campaigns = toSignal(
this.http.get<Campaign[]>('/api/campaigns'),
{ initialValue: [] as Campaign[] },
);
}
// Template — clean, OnPush-aware, no async pipe
template: `
@for (c of svc.campaigns(); track c.id) {
<mx-campaign-row [campaign]="c" />
}
`,
8.2 toObservable(signal) — turn a Signal into a stream for RxJS pipelines
// ✅ debounce a Signal-driven input through RxJS
@Component({...})
export class CampaignsSearch {
query = signal('');
private http = inject(HttpClient);
results = toSignal(
toObservable(this.query).pipe(
debounceTime(250),
distinctUntilChanged(),
switchMap(q => this.http.get<Campaign[]>(`/api/search?q=${q}`)),
),
{ initialValue: [] },
);
}
You get the ergonomics of Signals for state, and RxJS for the time-based debouncing/switching/race-cancelling.
8.3 The right mental model for interop
Form / DOM / UI state HTTP / WebSocket / event stream
───────────────────── ───────────────────────────────
SIGNAL RxJS
│ │
│ toObservable(signal) │
▼ │
Observable ────────────────► pipe(debounce/switch...│
│
▼
toSignal(obs$)
│
▼
SIGNAL (template, OnPush)
Bridge at the boundary. Keep each half native.
9. Migration path — what we did at Mattrx (and what we'd do again)
WEEK 1 — Easy wins
├── All local UI state (toggles, tabs, modals) → Signal
├── Simple BehaviorSubject<X> + asObservable() in services → signal<X>()
├── Derived chains (map → map) → computed()
└── Local 'persist to localStorage' subscriptions → effect()
WEEK 2 — Service-level shared state
├── combineLatest of stable values → computed()
├── Convert HTTP `.subscribe()` → toSignal(http.get(...))
└── Refactor /campaigns filters, /dashboard kpis, /settings (forms)
WEEK 3 — Touch the hard things last
├── /inbox WebSocket — KEEP RxJS, expose `latest` as toSignal()
├── Search inputs — KEEP RxJS for the debounce/switchMap pipe
├── Drag-to-resize / DOM event composition — KEEP RxJS
└── Document the "RxJS islands" with comments explaining why
WEEK 4 — Cleanup + measurement
├── Remove every now-unused `takeUntilDestroyed`
├── Audit `effect()` for accidental write-during-read (Angular warns)
├── Benchmark before/after on the 3 hottest pages
└── Push to prod, monitor INP for a week
Things that bit us:
effect()that writes a signal it reads — Angular warns about this; the warning is right. Useuntracked(() => signal.set(...))or restructure.- Forgetting
initialValueontoSignal()of aHttpClientObservable produces aSignal<T | undefined>— the template getsundefineduntil the request returns. Always supplyinitialValue. - Replacing a
BehaviorSubjectwhose subscribers depended on emit order —set()of the same value (by reference equality) is a no-op for Signals. That's usually a feature, occasionally a regression. Useupdate(_ => structuredClone(...))if you genuinely want to re-emit identical data.
10. Decision tree (the cheat sheet)
START
│
▼
Is this "the current value of X" (state)?
├── YES → SIGNAL
│ ├── Local to a component? → signal() in the class
│ ├── Shared across the app? → signal() in a service
│ └── Derived from other signals? → computed()
│
└── NO → It's an event stream / time-based / async pipeline
├── HTTP request? → HttpClient returns Observable; toSignal() if read as state
├── User input that needs debounce/throttle/switchMap? → RxJS
├── WebSocket / SSE / long-poll? → RxJS (webSocket() + retry + share)
├── DOM events that compose (drag, double-click, gesture)? → RxJS (fromEvent + operators)
└── Multiple async sources merged → RxJS (combineLatest / merge / forkJoin)
When in doubt: state → Signal, events → RxJS.
11. The Mattrx-style end-to-end pattern
A real /campaigns page using both, each where it fits:
@Component({
selector: 'mx-campaigns',
standalone: true,
imports: [CampaignRow, EmptyState, AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input #q (input)="query.set(q.value)" placeholder="Search…" />
@for (c of filtered(); track c.id) {
<mx-campaign-row [campaign]="c" [selected]="isSelected(c.id)()" />
} @empty {
<mx-empty-state title="No campaigns match" />
}
<footer>Showing {{ filtered().length }} of {{ all().length }}</footer>
`,
})
export class CampaignsPage {
private http = inject(HttpClient);
// STATE → Signal
query = signal('');
selected = signal<Set<string>>(new Set());
// EVENT STREAM (debounce + race cancel) → RxJS, exposed as Signal
all = toSignal(
toObservable(this.query).pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap(q => this.http.get<Campaign[]>(`/api/campaigns?q=${encodeURIComponent(q)}`)),
catchError(() => of([])),
),
{ initialValue: [] as Campaign[] },
);
// DERIVED → computed
filtered = computed(() =>
this.all().filter(c => c.name.toLowerCase().includes(this.query().toLowerCase())),
);
// SELECTION helpers — pure Signal logic
isSelected = (id: string) => computed(() => this.selected().has(id));
toggle(id: string) {
this.selected.update(set => {
const next = new Set(set);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
}
query,selected— state. Signal.- The HTTP fetch — event stream with cancellation. RxJS. Surfaced as a Signal so the template stays clean and OnPush-aware.
filtered— derived state.computed.
Roughly 30 lines, one component, no BehaviorSubject, no manual cleanup, no markForCheck, OnPush-friendly.
12. Honest stuff
- Signals are not a replacement for RxJS. Anyone who tells you to "delete all your Observables" hasn't shipped a real-time feature.
- RxJS is not legacy. It's actively maintained, it's the only sane answer for time-based event composition, and
HttpClientwill continue to return Observables for the foreseeable future. - The biggest perf wins come from the combination — Signals + OnPush + standalone + lazy loading +
@fortrack. Each amplifies the others. - Signals make state code obvious. That's a quality-of-life win as big as the performance win. New hires understand
signal()andcomputed()in 10 minutes; RxJS takes weeks. effect()is notuseEffect, despite the name. Use it for side effects, not for fetching data, not for reacting to props. Reach forcomputed()first.toSignalwithoutinitialValuereturnsSignal<T | undefined>. Templates getundefinedon first render. Always provideinitialValueforHttpClientObservables.
13. The mental checklist
Before reaching for a BehaviorSubject, ask:
- Am I modelling a value or a stream of events?
- Do I need debounce / throttle / switchMap / retry / multi-source merge? → RxJS.
- Is this local UI state? → Signal.
- Is this a derivation from other state? →
computed(). - Is this a side effect when state changes (logging, persisting)? →
effect(). - Does an
HttpClientcall live behind it? → Keep the Observable; wrap withtoSignal({ initialValue })for the template. - Does this need to survive route changes? → Service-level Signal (or RxJS
shared Observable).
14. Closing — the right mental model
In one line: Signals are nouns. RxJS is verbs.
- "What is the current set of selected campaigns?" → Signal (a noun).
- "What's happening when the user types?" → RxJS (a sequence of events over time).
Picking the right one becomes mechanical once you ask the question that way. Mix them at the boundary with toSignal and toObservable. Stop trying to make one tool do both jobs.
Three habits that prevent 90% of the pain in this guide:
- Default to Signals for state. Local, service-level, derived — all of it.
- Default to RxJS for streams. HTTP, WebSocket, debounced inputs, DOM events, anything time-based.
- Bridge with interop, don't fight it.
toSignal()andtoObservable()are not workarounds — they're the design.
Apply that, and the next "Signals or RxJS?" code review takes 10 seconds, not 30 minutes — and the answer is right.
Further reading
- Angular docs — Signals — the canonical reference.
- Angular docs — RxJS Interop —
toSignal/toObservable+ caveats. - Angular docs —
linkedSignal— the "draft mirrors server until edited" pattern in §5.5. - RxJS docs — operators — the 10 you actually need.
- Mike Pearson — Signals are not RxJS — the talk that crystallized this divide for the Angular community.
- PrepStack — Angular Performance Optimization Guide — the broader perf playbook this guide fits into.
Stuck on "Signals or RxJS?" in an Angular code review? Email randhir.jassal@gmail.com with the specific use case (what's the value, who reads it, is there time-based behaviour) — happy to point at which side of the line it falls on.
Get the next issue
A short, curated email with the newest posts and questions.