DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Angular & RxJS in 2025: The Expert’s Playbook (Signals, RxJS 8, and Interop)

Angular & RxJS in 2025: The Expert’s Playbook (Signals, RxJS 8, and Interop)

Angular & RxJS in 2025: The Expert’s Playbook (Signals, RxJS 8, and Interop)

Why this post? Between Angular 16→20 and RxJS 8, the way we build reactive Angular apps has changed. Signals now handle most local UI state, while Observables remain king for async and event streams. The sweet spot is interop—and that’s what this guide is about.


Table of Contents


What Changed (Angular 14 → 20)

Version Highlights
14 (2022) Standalone components (NgModule optional), Typed Forms, DI improvements.
15 (2022) Standalone API matures, tree‑shaking improves, Directive Composition API.
16 (2023) Signals arrive, SSR speedups, esbuild builder (faster builds).
17 (2023) @defer, better Signals integration, hydration tooling.
18 (2024) More Signals utilities, stronger SSR/partial hydration.
19 (2025) Hybrid reactivity polish, tighter RxJS interop, CLI perf insights.
20 (2025) Resource API rename (request/loaderparams/stream), expanded rxResource for querying & streaming, Zoneless-ready stacks, render hooks like afterNextRender.

Angular keeps getting leaner and more explicit. Signals are the default for component state; Observables power async and data streams.


RxJS 8+: What’s New

  • Modern TS types → better inference + editor help.
  • Smaller bundles → more tree‑shakable operators/helpers.
  • Interruption/cancellation → cleaner cleanup semantics.
  • Interop helpers → simpler conversion to/from Signals.
  • Ergonomics → compact operator signatures and safer defaults.

Rule of thumb: Use Observables for **events/async; convert to Signals when you need **template‑friendly* state.*


Signals vs Observables: When to Use Which

Use Case Prefer Why
Local UI state (toggles, form flags, selection) Signals Synchronous, minimal boilerplate, template‑friendly.
HTTP, WebSockets, user input streams, timers Observables Push‑based, cancelable, composable with operators.
Shared app state (store, cache) Both Fetch as Observable → expose as Signal for components.
Derived/computed state Signals computed() is explicit and cheap.
Complex async pipelines (retry, backoff) Observables Expressive operators, testable.

Minimal examples

Observable

import { Observable } from 'rxjs';

const observable$ = new Observable<number>(observer => {
  observer.next(1);
  observer.next(2);
  observer.next(3);
  observer.complete();
});

observable$.subscribe({ next: v => console.log(v) });
Enter fullscreen mode Exit fullscreen mode

Signal

import { signal } from '@angular/core';

const count = signal(0);
count.set(10);
console.log(count()); // 10
Enter fullscreen mode Exit fullscreen mode

Interop Patterns (toSignal, toObservable, rxResource)

1) Observable → Signal (UI‑ready)

import { toSignal } from '@angular/core/rxjs-interop';
import { interval, of, delay } from 'rxjs';

const t$ = interval(1000);
const t = toSignal(t$, { initialValue: 0 });   // read as t()

const user$ = of({ name: 'Ada' }).pipe(delay(1000));
const user = toSignal(user$);                  // read as user()?.name
Enter fullscreen mode Exit fullscreen mode

2) Signal → Observable (compose with RxJS)

import { toObservable } from '@angular/core/rxjs-interop';
import { signal } from '@angular/core';
import { switchMap, map } from 'rxjs';

const q = signal('angular');
const q$ = toObservable(q);

const results$ = q$.pipe(
  switchMap(term => fetch(`/api/search?q=${term}`).then(r => r.json())),
  map(r => r.items)
);
Enter fullscreen mode Exit fullscreen mode

3) Query data with rxResource (Angular 20 API)

import { rxResource } from '@angular/core/rxjs-interop';
import { HttpClient, HttpParams } from '@angular/common/http';
import { inject, signal, computed } from '@angular/core';

type Page = { total: number; items: any[] };
type Query = { q: string; limit: number; skip: number };

const http = inject(HttpClient);
const q = signal('');
const limit = signal(12);
const page = signal(1);
const skip = computed(() => (page() - 1) * limit());

const paramsSig = computed<Query>(() => ({ q: q().trim(), limit: limit(), skip: skip() }));

export const pageRef = rxResource<Page, Query>({
  params: () => paramsSig(),
  stream: ({ params, abortSignal }) => {
    let hp = new HttpParams().set('limit', params().limit).set('skip', params().skip);
    if (params().q) hp = hp.set('q', params().q);
    return http.get<Page>('/api/items', { params: hp, signal: abortSignal });
  },
  defaultValue: { total: 0, items: [] }
});
Enter fullscreen mode Exit fullscreen mode

In v20, the Resource API uses params + stream (renamed from request + loader). Status is a string union: 'idle' | 'loading' | 'reloading' | 'resolved' | 'error'.


UI Patterns: AsyncPipe, takeUntil, takeUntilDestroyed

AsyncPipe in templates

<div *ngIf="user$ | async as user">Hello, {{ user.name }}!</div>
Enter fullscreen mode Exit fullscreen mode

takeUntil for manual teardown

import { Subject, takeUntil } from 'rxjs';

const destroy$ = new Subject<void>();
data$.pipe(takeUntil(destroy$)).subscribe();
ngOnDestroy() { destroy$.next(); destroy$.complete(); }
Enter fullscreen mode Exit fullscreen mode

takeUntilDestroyed (ergonomic cleanup)

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

readonly destroyed = inject(takeUntilDestroyed());
data$.pipe(destroyed).subscribe();
Enter fullscreen mode Exit fullscreen mode

Performance Checklist (2025)

  • Prefer Signals for hot UI paths (fewer subscriptions, less churn).
  • Convert streams at the component boundary via toSignal.
  • Use rxResource for request lifecycle + status UI + cancellation.
  • Debounce keystrokes before HTTP, not after.
  • Track lists by stable keys, not $index.
  • Embrace Zoneless + render hooks (afterNextRender) when feasible.
  • Run ng analyze-performance (CLI insights) and measure Core Web Vitals.

Full Example: Search + Pagination with Signals + RxJS

import { Component, computed, effect, inject, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { rxResource } from '@angular/core/rxjs-interop';
import { NgIf, NgFor } from '@angular/common';

interface Item { id: number; title: string; price: number }
interface Page { total: number; items: Item[] }
interface Query { q: string; limit: number; skip: number }

@Component({
  selector: 'app-catalog',
  standalone: true,
  imports: [NgIf, NgFor],
  template: `
  <section class="p-6 space-y-4">
    <header class="flex flex-wrap items-center gap-3">
      <input class="input input-bordered w-full sm:w-80" type="search"
             placeholder="Search…" (input)="q.set(($event.target as HTMLInputElement).value)" />
      <select class="select select-bordered" (change)="limit.set(+$any($event.target).value)">
        <option [selected]="limit()===12" value="12">12</option>
        <option [selected]="limit()===24" value="24">24</option>
        <option [selected]="limit()===48" value="48">48</option>
      </select>
      <button class="btn" (click)="pageRef.reload()" [disabled]="pageRef.isLoading()">Reload</button>
      <span class="ml-auto text-sm opacity-70">Status: {{ pageRef.status() }}</span>
    </header>

    <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
      @for (p of pageRef.value().items; track p.id) {
        <article class="card bg-base-200">
          <div class="card-body p-3">
            <h3 class="card-title text-sm">{{ p.title }}</h3>
            <div class="text-xs opacity-70">${{ p.price }}</div>
          </div>
        </article>
      }
    </div>

    <footer class="flex items-center gap-3">
      <button class="btn" (click)="prev()" [disabled]="page()===1 || pageRef.isLoading()">Prev</button>
      <div class="opacity-70">Page {{ page() }}</div>
      <button class="btn" (click)="next()" [disabled]="!hasMore() || pageRef.isLoading()">Next</button>
    </footer>
  </section>
  `,
})
export class CatalogComponent {
  private http = inject(HttpClient);

  // Query state
  readonly q = signal('');
  readonly limit = signal(12);
  readonly page = signal(1);
  readonly skip = computed(() => (this.page() - 1) * this.limit());
  readonly paramsSig = computed<Query>(() => ({ q: this.q().trim(), limit: this.limit(), skip: this.skip() }));

  // Resource
  readonly pageRef = rxResource<Page, Query>({
    params: () => this.paramsSig(),
    stream: ({ params, abortSignal }) => {
      let hp = new HttpParams().set('limit', params().limit).set('skip', params().skip);
      if (params().q) hp = hp.set('q', params().q);
      return this.http.get<Page>('/api/items', { params: hp, signal: abortSignal });
    },
    defaultValue: { total: 0, items: [] },
  });

  readonly hasMore = computed(() => {
    const { total, items } = this.pageRef.value();
    return (this.skip() + items.length) < total;
  });

  next() { this.page.update(p => p + 1); }
  prev() { this.page.update(p => Math.max(1, p - 1)); }

  constructor() {
    // Keep page within range when filters change
    effect(() => {
      const total = this.pageRef.value().total;
      const pages = Math.max(1, Math.ceil(total / this.limit()));
      if (this.page() > pages) this.page.set(pages);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Migration Notes & Anti‑Patterns

Renames (v19 → v20)

  • requestparams
  • loaderstream
  • ResourceStatus → string union: 'idle' | 'loading' | 'reloading' | 'resolved' | 'error'

Avoid

  • Subscribing in components when AsyncPipe suffices.
  • Emitting mutable objects from Signals (use immutable updates).
  • Calling HTTP directly in effect() without guards (can loop).
  • Using $index for @for tracking — prefer track item.id.

Prefer

  • Provide defaultValue in resources to avoid undefined guards.
  • Keep side effects out of stream—only produce an Observable/Promise.
  • Co-locate state in Signals, async in RxJS; bridge with interop helpers.

Conclusion

Angular + RxJS in 2025 is all about explicit reactivity and clean interop. Use Signals for local state and derivations; keep Observables for async, events, and complex pipelines; glue them with toSignal/toObservable and rxResource. Your apps get faster, smaller, and easier to reason about.

✍️ Written by: Cristian Sifuentes — Full-stack developer & AI/JS enthusiast, passionate about React, TypeScript, and scalable architectures.

Top comments (0)