| name | angular-rxjs-patterns |
| user-invocable | false |
| description | Use when handling async operations in Angular applications with observables, operators, and subjects. |
| allowed-tools | ["Bash","Read"] |
Angular RxJS Patterns
Master RxJS in Angular for handling async operations, data streams,
and reactive programming patterns.
Observable Creation
Basic Observable Creation
import { Observable, of, from, interval, fromEvent } from 'rxjs'
const numbers$ = of(1, 2, 3, 4, 5)
const fromArray$ = from([1, 2, 3])
const fromPromise$ = from(fetch('/api/data'))
const timer$ = interval(1000)
const clicks$ = fromEvent(document, 'click')
const custom$ = new Observable((subscriber) => {
subscriber.next(1)
subscriber.next(2)
subscriber.complete()
})
HttpClient Observables
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class DataService {
private readonly http = inject(HttpClient)
getData(): Observable<Data[]> {
return this.http.get<Data[]>('/api/data')
}
getItem(id: string): Observable<Data> {
return this.http.get<Data>(`/api/data/${id}`)
}
createItem(data: Data): Observable<Data> {
return this.http.post<Data>('/api/data', data)
}
updateItem(id: string, data: Data): Observable<Data> {
return this.http.put<Data>(`/api/data/${id}`, data)
}
deleteItem(id: string): Observable<void> {
return this.http.delete<void>(`/api/data/${id}`)
}
}
Common Operators
Transformation Operators
import { map, pluck, switchMap, mergeMap, concatMap } from 'rxjs/operators'
import { of } from 'rxjs'
const numbers$ = of(1, 2, 3).pipe(
map((n) => n * 2)
)
const users$ = of({ name: 'John', age: 30 }, { name: 'Jane', age: 25 }).pipe(
map((user) => user.name)
)
searchControl.valueChanges.pipe(switchMap((term) => this.searchService.search(term))).subscribe((results) => {
this.results = results
})
const ids$ = of(1, 2, 3)
ids$
.pipe(
mergeMap((id) => this.getUser(id))
)
.subscribe()
ids$
.pipe(
concatMap((id) => this.getUser(id))
)
.subscribe()
Filtering Operators
import { filter, take, takeUntil, takeWhile, distinctUntilChanged } from 'rxjs/operators'
of(1, 2, 3, 4, 5).pipe(
filter((n) => n % 2 === 0)
)
interval(1000).pipe(
take(5)
)
const destroy$ = new Subject()
source$.pipe(takeUntil(destroy$)).subscribe()
of(1, 1, 2, 2, 3, 3).pipe(
distinctUntilChanged()
)
Combination Operators
import { combineLatest, merge, concat, forkJoin, zip } from 'rxjs'
import { startWith } from 'rxjs/operators'
combineLatest([this.user$, this.settings$])
.pipe(map(([user, settings]) => ({ user, settings })))
.subscribe()
merge(this.clicks$, this.hovers$).subscribe()
concat(this.loadUser$, this.loadSettings$).subscribe()
forkJoin({
user: this.getUser(),
posts: this.getPosts(),
comments: this.getComments(),
}).subscribe(({ user, posts, comments }) => {
})
zip(of(1, 2, 3), of('a', 'b', 'c')).pipe(map(([num, letter]) => `${num}${letter}`))
Utility Operators
import { tap, delay, debounceTime, throttleTime, distinctUntilChanged } from 'rxjs/operators'
source$.pipe(
tap((value) => console.log('Value:', value)),
map((value) => value * 2)
)
of(1, 2, 3).pipe(
delay(1000)
)
searchControl.valueChanges.pipe(
debounceTime(300)
)
clicks$.pipe(
throttleTime(1000)
)
input$.pipe(
distinctUntilChanged()
)
Error Handling
catchError - Handle Errors
import { catchError } from 'rxjs/operators'
import { of, EMPTY, throwError } from 'rxjs'
this.http.get('/api/data').pipe(
catchError((error) => {
console.error('Error:', error)
return of([])
})
)
source$.pipe(
catchError(() => EMPTY)
)
source$.pipe(
catchError((error) => {
console.error('Error:', error)
return throwError(() => new Error('Custom error'))
})
)
source$.pipe(
catchError((error) => {
if (error.status === 404) {
return of(null)
}
return throwError(() => error)
})
)
retry and retryWhen
import { retry, retryWhen, delay, take } from 'rxjs/operators'
this.http.get('/api/data').pipe(
retry(3)
)
this.http.get('/api/data').pipe(
retryWhen((errors) =>
errors.pipe(
delay(1000),
take(3)
)
)
)
this.http.get('/api/data').pipe(
retryWhen((errors) =>
errors.pipe(
mergeMap((error, index) => {
if (index >= 3) {
return throwError(() => error)
}
const delayMs = Math.pow(2, index) * 1000
return of(error).pipe(delay(delayMs))
})
)
)
)
Subscription Management
takeUntilDestroyed (Preferred)
Use takeUntilDestroyed() from @angular/core/rxjs-interop — no ngOnDestroy
needed, and no manual Subject<void> to manage:
import { Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
@Component({
selector: 'app-my-component',
standalone: true,
})
export class MyComponent {
private readonly dataService = inject(DataService)
constructor() {
this.dataService.data$
.pipe(
takeUntilDestroyed()
)
.subscribe((data) => {
this.data = data
})
this.dataService.other$.pipe(takeUntilDestroyed()).subscribe((other) => {
this.other = other
})
}
}
DestroyRef for Manual Subscriptions
When subscribing imperatively outside the constructor (e.g. on user action),
use DestroyRef directly:
import { Component, inject } from '@angular/core'
import { DestroyRef } from '@angular/core'
@Component({
selector: 'app-my-component',
standalone: true,
})
export class MyComponent {
readonly #destroyRef = inject(DestroyRef)
startPolling() {
const sub = interval(5000).subscribe(() => this.poll())
this.#destroyRef.onDestroy(() => sub.unsubscribe())
}
}
Async Pipe (No Manual Unsubscribe)
import { Component, inject } from '@angular/core'
import { AsyncPipe } from '@angular/common'
import { Observable } from 'rxjs'
@Component({
selector: 'app-user-list',
standalone: true,
imports: [AsyncPipe],
template: `
@if (users$ | async; as users) {
@for (user of users; track user.id) {
<div>{{ user.name }}</div>
}
}
@if (loading$ | async) {
<div>Loading...</div>
}
@if (error$ | async; as error) {
<div>Error: {{ error }}</div>
}
`,
})
export class UserListComponent {
private readonly userService = inject(UserService)
users$: Observable<User[]> = this.userService.getUsers()
loading$: Observable<boolean> = this.userService.loading$
error$: Observable<string | null> = this.userService.error$
}
Subjects
Subject - Multicast
import { Subject } from 'rxjs'
const subject = new Subject<number>()
subject.subscribe((val) => console.log('A:', val))
subject.subscribe((val) => console.log('B:', val))
subject.next(1)
subject.next(2)
BehaviorSubject - Current Value
import { BehaviorSubject } from 'rxjs'
const subject = new BehaviorSubject<number>(0)
subject.subscribe((val) => console.log('A:', val))
subject.next(1)
subject.next(2)
subject.subscribe((val) => console.log('B:', val))
@Injectable({
providedIn: 'root',
})
export class StateService {
private stateSubject = new BehaviorSubject<State>(initialState)
state$ = this.stateSubject.asObservable()
updateState(newState: State) {
this.stateSubject.next(newState)
}
get currentState(): State {
return this.stateSubject.value
}
}
ReplaySubject - Buffer Values
import { ReplaySubject } from 'rxjs'
const subject = new ReplaySubject<number>(2)
subject.next(1)
subject.next(2)
subject.next(3)
subject.subscribe((val) => console.log('A:', val))
subject.next(4)
subject.subscribe((val) => console.log('B:', val))
AsyncSubject - Last Value on Complete
import { AsyncSubject } from 'rxjs'
const subject = new AsyncSubject<number>()
subject.subscribe((val) => console.log('A:', val))
subject.next(1)
subject.next(2)
subject.next(3)
subject.complete()
Hot vs Cold Observables
Cold Observable - Unicast
const cold$ = interval(1000)
cold$.subscribe((val) => console.log('A:', val))
setTimeout(() => {
cold$.subscribe((val) => console.log('B:', val))
}, 2000)
Hot Observable - Multicast
import { Subject, interval } from 'rxjs'
import { share, shareReplay } from 'rxjs/operators'
const subject = new Subject()
const source$ = interval(1000)
source$.subscribe(subject)
subject.subscribe((val) => console.log('A:', val))
setTimeout(() => {
subject.subscribe((val) => console.log('B:', val))
}, 2000)
const shared$ = interval(1000).pipe(share())
shared$.subscribe((val) => console.log('A:', val))
setTimeout(() => {
shared$.subscribe((val) => console.log('B:', val))
}, 2000)
const cached$ = this.http.get('/api/data').pipe(
shareReplay(1)
)
cached$.subscribe()
cached$.subscribe()
RxJS in Services
Data Service with State
Use signals for synchronous state. Use observables for async HTTP operations,
converting them to signals with toSignal() when needed in templates:
import { Injectable, inject, signal, computed } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Observable, catchError, finalize, tap } from 'rxjs'
import { of } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class UserService {
private readonly http = inject(HttpClient)
readonly users = signal<User[]>([])
readonly loading = signal(false)
readonly error = signal<string | null>(null)
readonly hasUsers = computed(() => this.users().length > 0)
loadUsers(): void {
this.loading.set(true)
this.error.set(null)
this.http
.get<User[]>('/api/users')
.pipe(
tap((users) => this.users.set(users)),
catchError((err) => {
this.error.set(err.message)
return of([])
}),
finalize(() => this.loading.set(false))
)
.subscribe()
}
getUser(id: string): Observable<User> {
return this.http.get<User>(`/api/users/${id}`)
}
}
Template with signals (no async pipe needed for signal state):
@Component({
selector: 'app-user-list',
standalone: true,
template: `
@if (userService.loading()) {
<div>Loading...</div>
}
@if (userService.error(); as error) {
<div>Error: {{ error }}</div>
}
@for (user of userService.users(); track user.id) {
<div>{{ user.name }}</div>
}
`,
})
export class UserListComponent {
protected readonly userService = inject(UserService)
constructor() {
this.userService.loadUsers()
}
}
Search Service with Debounce
import { Injectable, inject } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { Observable, Subject, of } from 'rxjs'
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators'
@Injectable({
providedIn: 'root',
})
export class SearchService {
private readonly http = inject(HttpClient)
private readonly searchTerms = new Subject<string>()
readonly results$: Observable<SearchResult[]> = this.searchTerms.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((term) => this.search(term))
)
search(term: string): Observable<SearchResult[]> {
if (!term.trim()) {
return of([])
}
return this.http.get<SearchResult[]>(`/api/search?q=${term}`)
}
setSearchTerm(term: string): void {
this.searchTerms.next(term)
}
}
Testing RxJS
Testing Observables
import { TestBed } from '@angular/core/testing'
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'
describe('UserService', () => {
let service: UserService
let httpMock: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [UserService],
})
service = TestBed.inject(UserService)
httpMock = TestBed.inject(HttpTestingController)
})
afterEach(() => {
httpMock.verify()
})
it('should fetch users', () => {
const mockUsers = [{ id: 1, name: 'John' }]
service.getUsers().subscribe((users) => {
expect(users).toEqual(mockUsers)
})
const req = httpMock.expectOne('/api/users')
expect(req.request.method).toBe('GET')
req.flush(mockUsers)
})
})
Testing with Marble Diagrams
import { TestScheduler } from 'rxjs/testing'
describe('Marble tests', () => {
let scheduler: TestScheduler
beforeEach(() => {
scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected)
})
})
it('should debounce', () => {
scheduler.run(({ cold, expectObservable }) => {
const source$ = cold('-a-b-c|')
const expected = '-----c|'
const result$ = source$.pipe(debounceTime(20))
expectObservable(result$).toBe(expected)
})
})
})
When to Use This Skill
Use angular-rxjs-patterns when building modern, production-ready
applications that require:
- Complex async data flows
- Real-time updates and streaming data
- Efficient HTTP request management
- Form input handling with debouncing
- State management with observables
- Error handling and retry logic
- Combining multiple async sources
- Memory-safe subscription management
RxJS Best Practices in Angular
- Use
takeUntilDestroyed() - Automatic cleanup without ngOnDestroy
- Use signals for state -
BehaviorSubject → signal() for synchronous state
- Use async pipe for observables in templates - Automatic subscription management
- Use
@if/@for control flow - Replaces *ngIf/*ngFor
- Use
inject() - Cleaner than constructor injection
- shareReplay for caching - Avoid duplicate HTTP requests
- debounceTime for inputs - Reduce API calls
- switchMap for cancellation - Cancel old requests
- catchError for errors - Always handle errors
- Test observables properly - Use marble diagrams
Common RxJS Mistakes
- Not unsubscribing - Memory leaks
- Nested subscriptions - Callback hell
- Not using operators - Imperative instead of declarative
- Subscribing in services - Return observables instead
- Not handling errors - Silent failures
- Using Subject incorrectly - Prefer BehaviorSubject for state
- Not using shareReplay - Duplicate HTTP requests
- Forgetting to complete subjects - Memory leaks
- Using subscribe in templates - Use async pipe
- Not understanding hot vs cold - Unexpected behavior
Resources