Angular Signals in Practice: Replacing RxJS Boilerplate in a Real Dashboard
Signals didn't kill RxJS in our codebase — they killed the ceremony around it. Here's what actually changed when I migrated a live HRM dashboard.
The dashboard I inherited had a BehaviorSubject for every piece of screen state: filters, pagination, the selected employee, the loading flag. Each one came with a subscription, an async pipe, or worse — a manual unsubscribe() in ngOnDestroy that somebody forgot half the time. The logic wasn't wrong. It was just buried under plumbing.
Signals removed that plumbing. Not the reactivity — the plumbing. That distinction matters, because the loudest takes online frame this as "Signals vs RxJS", and that framing led our team down a wrong path for about two weeks.
What the code looked like before
export class EmployeeListComponent implements OnInit, OnDestroy {
private search$ = new BehaviorSubject<string>('');
private page$ = new BehaviorSubject<number>(1);
employees: Employee[] = [];
loading = false;
private destroy$ = new Subject<void>();
ngOnInit() {
combineLatest([this.search$, this.page$])
.pipe(
tap(() => (this.loading = true)),
debounceTime(300),
switchMap(([q, page]) => this.api.list(q, page)),
takeUntil(this.destroy$),
)
.subscribe((res) => {
this.employees = res.items;
this.loading = false;
});
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}Four fields, two lifecycle hooks, and a destroy$ subject to express one idea: when the search or the page changes, fetch a list.
What it looks like now
export class EmployeeListComponent {
search = signal('');
page = signal(1);
private query = computed(() => ({ q: this.search(), page: this.page() }));
employees = resource({
request: this.query,
loader: ({ request }) => this.api.list(request.q, request.page),
});
// template: @if (employees.isLoading()) { ... }
}No subscription, no teardown, no loading flag I have to remember to flip back to false in an error branch. The component reads top-to-bottom as a description of state rather than a sequence of wiring steps.
The rule I settled on
After migrating about forty components, the split that held up is embarrassingly simple:
- Signals for state — anything the template reads. Current filter, selected row, form mode, derived totals. State is a value that exists right now, and a signal is a value that exists right now.
- RxJS for events — anything that is a stream over time. WebSocket messages,
fromEventon window resize, retry-with-backoff, anything needingdebounceTimeorswitchMapsemantics on a genuine sequence. - `toSignal` at the boundary — where a stream lands in a component, convert it once and let the rest of the component be signal-based.
If you can answer "what is its value right now?" it's a signal. If the only honest answer is "depends when you ask", it's an observable.
The mistakes I made
1. Rewriting working RxJS for the sake of it
We had a notifications pipeline with retry, backoff, and dedupe. I spent a day trying to express it with signals and effect(). It was worse in every dimension. I reverted it. Retry and backoff are time-domain problems, and RxJS is a time-domain library.
2. Using `effect()` as a general-purpose watcher
effect() is for synchronising with things outside Angular — logging, the DOM, localStorage, a charting library. It is not the place to derive state. Every time I wrote an effect that set another signal, I later replaced it with a computed, and the code got shorter each time.
// wrong — an effect that computes
effect(() => this.total.set(this.items().reduce((a, b) => a + b.amount, 0)));
// right — a computed that computes
total = computed(() => this.items().reduce((a, b) => a + b.amount, 0));3. Forgetting that signals are equality-checked
Mutating an array and calling set() with the same reference does nothing. This bit us twice in a table component before it became muscle memory to always produce a new reference.
What actually improved
- Component files shrank by roughly a third — almost entirely lifecycle and teardown code.
- Change detection got measurably cheaper: signal reads mark only the components that read them, so
OnPushcorrectness stopped being something we had to reason about. - New developers became productive faster.
computedis a concept you explain in one sentence;switchMapversusmergeMapis a conversation.
The thing I'd tell my past self: don't treat this as a migration project. Treat it as a default for new code and a cleanup you do when you're already touching a file. We finished the meaningful 80% that way without ever booking a single "Signals migration" ticket.