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)
- RxJS 8+: What’s New
- Signals vs Observables: When to Use Which
- Interop Patterns (toSignal, toObservable, rxResource)
- UI Patterns: AsyncPipe, takeUntil, takeUntilDestroyed
- Performance Checklist (2025)
- Full Example: Search + Pagination with Signals + RxJS
- Migration Notes & Anti‑Patterns
- Conclusion
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/loader → params/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) });
Signal
import { signal } from '@angular/core';
const count = signal(0);
count.set(10);
console.log(count()); // 10
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
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)
);
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: [] }
});
In v20, the Resource API uses
params+stream(renamed fromrequest+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>
takeUntil for manual teardown
import { Subject, takeUntil } from 'rxjs';
const destroy$ = new Subject<void>();
data$.pipe(takeUntil(destroy$)).subscribe();
ngOnDestroy() { destroy$.next(); destroy$.complete(); }
takeUntilDestroyed (ergonomic cleanup)
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
readonly destroyed = inject(takeUntilDestroyed());
data$.pipe(destroyed).subscribe();
Performance Checklist (2025)
- Prefer Signals for hot UI paths (fewer subscriptions, less churn).
- Convert streams at the component boundary via
toSignal. - Use
rxResourcefor 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);
});
}
}
Migration Notes & Anti‑Patterns
Renames (v19 → v20)
-
request→params -
loader→stream -
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
$indexfor@fortracking — prefertrack item.id.
Prefer
- Provide
defaultValuein resources to avoidundefinedguards. - 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)