Skip to content
    All posts
    Angular

    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.

    3 min readby

    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

    ts
    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

    ts
    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, fromEvent on window resize, retry-with-backoff, anything needing debounceTime or switchMap semantics 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.

    ts
    // 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 OnPush correctness stopped being something we had to reason about.
    • New developers became productive faster. computed is a concept you explain in one sentence; switchMap versus mergeMap is 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.

    AngularSignalsRxJSState

    Keep reading

    PAPulapa Arun Kumar

    Full-stack developer building performant, clean, and user-friendly web & mobile applications. Also written as Arun Kumar Pulapa — same person, surname first.

    Built with

    ReactTypeScriptTailwind CSSVite

    © 2026 Pulapa Arun Kumar. All rights reserved.