RxJS in Angular - Chapter 10 (Final) | Real-World Patterns, Best Practices & Everything That Actually Matters
The State Service Pattern (Mini State Management)
In large Angular apps, managing shared state is the biggest challenge. You don't always need NgRx - a simple service with BehaviorSubject is often enough.
The Pattern
// feature-state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// Define the shape of your state
interface ProductState {
products: Product[];
selectedProduct: Product | null;
isLoading: boolean;
error: string | null;
filters: {
category: string;
maxPrice: number;
searchTerm: string;
};
}
// Initial state
const initialState: ProductState = {
products: [],
selectedProduct: null,
isLoading: false,
error: null,
filters: {
category: 'all',
maxPrice: 999999,
searchTerm: ''
}
};
@Injectable({ providedIn: 'root' })
export class ProductStateService {
// The one source of truth
private state$ = new BehaviorSubject<ProductState>(initialState);
// Derived selectors - computed from state
products$ = this.state$.pipe(map(s => s.products));
selectedProduct$ = this.state$.pipe(map(s => s.selectedProduct));
isLoading$ = this.state$.pipe(map(s => s.isLoading));
error$ = this.state$.pipe(map(s => s.error));
// Filtered products - automatically recomputes when state changes
filteredProducts$ = this.state$.pipe(
map(state => {
return state.products.filter(p =>
(state.filters.category === 'all' || p.category === state.filters.category) &&
p.price <= state.filters.maxPrice &&
p.name.toLowerCase().includes(state.filters.searchTerm.toLowerCase())
);
})
);
// State updaters - the only way to change state
setLoading(isLoading: boolean): void {
this.updateState({ isLoading });
}
setProducts(products: Product[]): void {
this.updateState({ products, isLoading: false, error: null });
}
setError(error: string): void {
this.updateState({ error, isLoading: false });
}
selectProduct(product: Product | null): void {
this.updateState({ selectedProduct: product });
}
updateFilters(filters: Partial<ProductState['filters']>): void {
const currentFilters = this.state$.value.filters;
this.updateState({ filters: { ...currentFilters, ...filters } });
}
// Private helper - immutable state update
private updateState(partialState: Partial<ProductState>): void {
this.state$.next({ ...this.state$.value, ...partialState });
}
// Synchronous getter for imperative code
get currentState(): ProductState {
return this.state$.value;
}
}
Using the State Service in Components
// product-list.component.ts
@Component({
template: `
<div *ngIf="state.isLoading$ | async">Loading... ⏳</div>
<div *ngIf="state.error$ | async as err" class="error">{{ err }}</div>
<div *ngFor="let product of state.filteredProducts$ | async">
<h3>{{ product.name }}</h3>
<button (click)="state.selectProduct(product)">View</button>
</div>
`
})
export class ProductListComponent implements OnInit {
constructor(
public state: ProductStateService,
private productService: ProductService
) {}
ngOnInit(): void {
this.state.setLoading(true);
this.productService.getAll().subscribe({
next: products => this.state.setProducts(products),
error: err => this.state.setError(err.message)
});
}
}
// filter-bar.component.ts
@Component({
template: `
<input [formControl]="search" placeholder="Search...">
<select [formControl]="category">
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
</select>
`
})
export class FilterBarComponent implements OnInit, OnDestroy {
search = new FormControl('');
category = new FormControl('all');
private destroy$ = new Subject<void>();
constructor(private state: ProductStateService) {}
ngOnInit(): void {
this.search.valueChanges
.pipe(debounceTime(300), takeUntil(this.destroy$))
.subscribe(term => this.state.updateFilters({ searchTerm: term || '' }));
this.category.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(cat => this.state.updateFilters({ category: cat || 'all' }));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
The filter component and the list component are completely decoupled - they communicate through the state service! 🎯
Pattern 2: The Smart/Dumb Component Pattern
Smart components manage data and subscriptions. Dumb components just display data via @Input and emit events via @Output.
// SMART component - manages data
@Component({
selector: 'app-users-page',
template: `
<!-- Pass data DOWN to dumb component via @Input -->
<app-user-list
[users]="users$ | async"
[isLoading]="isLoading$ | async"
(userSelected)="onUserSelected($event)"
(deleteRequested)="onDeleteUser($event)">
</app-user-list>
`
})
export class UsersPageComponent {
users$ = this.userService.getUsers();
isLoading$ = new BehaviorSubject(true);
constructor(private userService: UserService) {
this.users$.subscribe(() => this.isLoading$.next(false));
}
onUserSelected(user: User): void { ... }
onDeleteUser(userId: number): void { ... }
}
// DUMB component - only displays, zero knowledge of services
@Component({
selector: 'app-user-list',
template: `
<div *ngIf="isLoading">Loading...</div>
<div *ngFor="let user of users">
{{ user.name }}
<button (click)="userSelected.emit(user)">View</button>
<button (click)="deleteRequested.emit(user.id)">Delete</button>
</div>
`
})
export class UserListComponent {
@Input() users: User[] | null = [];
@Input() isLoading: boolean | null = false;
@Output() userSelected = new EventEmitter<User>();
@Output() deleteRequested = new EventEmitter<number>();
}
Dumb components are easy to test, reuse, and understand! 💡
Pattern 3: The Loading/Error/Success State Pattern
Every page that fetches data should handle 3 states. Here's the cleanest way:
// Shared types
interface AsyncState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
}
// Helper function
function createLoadingState<T>(): AsyncState<T> {
return { data: null, isLoading: true, error: null };
}
// In your component
@Component({
template: `
<ng-container *ngIf="state$ | async as s">
<app-skeleton *ngIf="s.isLoading"></app-skeleton>
<app-error-message
*ngIf="s.error"
[message]="s.error"
(retry)="load()">
</app-error-message>
<app-product-grid
*ngIf="s.data && !s.isLoading"
[products]="s.data">
</app-product-grid>
</ng-container>
`
})
export class ProductPageComponent implements OnInit {
state$!: Observable<AsyncState<Product[]>>;
constructor(private productService: ProductService) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.state$ = this.productService.getProducts().pipe(
map(products => ({ data: products, isLoading: false, error: null })),
catchError(err => of({ data: null, isLoading: false, error: err.message })),
startWith(createLoadingState<Product[]>())
);
}
}
🛑 The Top 10 RxJS Mistakes to Avoid
Mistake 1: Not Unsubscribing
// ❌ Memory leak!
ngOnInit() {
interval(1000).subscribe(n => console.log(n));
}
// ✅ Proper cleanup
ngOnInit() {
interval(1000)
.pipe(takeUntil(this.destroy$))
.subscribe(n => console.log(n));
}
Mistake 2: Nested Subscribes
// ❌ Never do this - nested subscribes!
this.route.params.subscribe(params => {
this.userService.getUser(params['id']).subscribe(user => {
this.user = user;
});
});
// ✅ Use switchMap
this.route.params.pipe(
switchMap(params => this.userService.getUser(params['id']))
).subscribe(user => this.user = user);
Mistake 3: Subscribing in Services
// ❌ Bad - subscribing in service creates untraceable subscriptions
@Injectable()
export class UserService {
loadUsers() {
this.http.get('/api/users').subscribe(users => {
this.users = users; // Mutating service state in subscribe!
});
}
}
// ✅ Good - return the Observable, let the component decide
@Injectable()
export class UserService {
getUsers(): Observable<User[]> {
return this.http.get<User[]>('/api/users');
}
}
Mistake 4: Forgetting startWith in combineLatest
// ❌ combineLatest won't emit until ALL observables have emitted at least once
combineLatest([
this.searchControl.valueChanges, // Won't emit until user types!
this.categoryControl.valueChanges
])
// ✅ Use startWith so combineLatest fires immediately
combineLatest([
this.searchControl.valueChanges.pipe(startWith('')),
this.categoryControl.valueChanges.pipe(startWith('all'))
])
Mistake 5: Using switchMap When You Should Use mergeMap
// ❌ Wrong - switchMap cancels previous file uploads!
from(files).pipe(
switchMap(file => this.upload(file)) // Each new file cancels the previous upload!
)
// ✅ Correct - mergeMap runs all uploads in parallel
from(files).pipe(
mergeMap(file => this.upload(file))
)
Mistake 6: Not Handling Errors
// ❌ If API fails, the entire Observable dies - no recovery
this.http.get('/api/data').subscribe(data => this.data = data);
// ✅ Always catch errors
this.http.get('/api/data').pipe(
catchError(err => {
this.error = 'Failed to load';
return of([]);
})
).subscribe(data => this.data = data);
Mistake 7: Overcomplicating Simple Things
// ❌ Too complex for a simple HTTP call
this.http.get('/api/users').pipe(
take(1), // Not needed - HTTP already completes after one emission
first(), // Also not needed
shareReplay(1) // Also unnecessary for a one-time load
)
// ✅ Keep it simple
this.http.get('/api/users')
Mistake 8: Not Using async Pipe
// ❌ Manual subscribe = manual unsubscribe burden
ngOnInit() {
this.userService.getUsers().subscribe(users => this.users = users);
}
// ✅ async pipe handles everything
users$ = this.userService.getUsers();
// Template: *ngFor="let user of users$ | async"
Mistake 9: Exposing Subject Directly
// ❌ Any component can push values - breaks encapsulation!
cartItems$ = new BehaviorSubject<CartItem[]>([]);
// ✅ Private subject, public Observable
private cartItemsSubject = new BehaviorSubject<CartItem[]>([]);
cartItems$ = this.cartItemsSubject.asObservable();
Mistake 10: Using tap to Extract Data
// ❌ tap should not be used to extract values like this
let userData: User;
this.getUser().pipe(
tap(user => userData = user) // Side effect to extract - bad pattern!
).subscribe();
// ✅ Use the data in subscribe or map
this.getUser().subscribe(user => {
this.userData = user;
});
📋 The Complete RxJS Cheat Sheet
Creating Observables
of(1,2,3)- from static valuesfrom([1,2,3])- from array or Promiseinterval(ms)- ticks every N mstimer(ms)- emits once after delayfromEvent(el, 'click')- from DOM eventnew Observable(observer => ...)- custom
Transforming
map(fn)- transform each valuefilter(fn)- keep only matching valuestap(fn)- side effects, no change
Flattening (Observables of Observables)
switchMap(fn)- cancel old, start new (search, route)mergeMap(fn)- run all in parallel (uploads)concatMap(fn)- run one at a time (sequential)
Timing
debounceTime(ms)- wait for silence (search box)throttleTime(ms)- limit rate (scroll events)distinctUntilChanged()- skip duplicatestake(n)- take first N, then completetakeUntil(obs$)- take until signal
Combining
forkJoin({...})- parallel, wait for allcombineLatest([...])- emit on any changewithLatestFrom(obs$)- snapshot on triggerzip(obs1, obs2)- pair by position
Error Handling
catchError(fn)- catch and recoverretry(n)- retry N timesfinalize(fn)- always runs last
Subjects
Subject- multicast, no memoryBehaviorSubject(init)- remembers latestReplaySubject(n)- remembers last N
🎓 Your Journey So Far - The Complete Series
Here's everything you've learned across all 10 chapters:
- Chapter 1 - Observables: the data stream
- Chapter 2 - Subscribe & unsubscribe: open and close the tap
- Chapter 3 - pipe, map, filter, tap: transform your data
- Chapter 4 - switchMap, mergeMap, concatMap: handle nested streams
- Chapter 5 - Subject & BehaviorSubject: broadcast to many
- Chapter 6 - Error handling: catchError, retry, finalize
- Chapter 7 - forkJoin, combineLatest: combine multiple streams
- Chapter 8 - Reactive Forms + RxJS: live validation and dynamic forms
- Chapter 9 - Timing operators: debounceTime, throttleTime, interval
- Chapter 10 - Real-world patterns and best practices (this chapter!)
🚀 What to Learn Next
You're no longer a beginner! Here's your roadmap to RxJS mastery:
- NgRx - Formal state management built on RxJS (for very large apps)
- Angular Signals - Angular's newer reactivity system (complements RxJS)
- shareReplay() - Share a single HTTP response between multiple subscribers
- Custom Operators - Build your own reusable operators
- Testing RxJS - Use TestScheduler and marble testing
- RxJS in Node.js - Use the same patterns on the backend
💌 Thank You for Reading!
You've completed the entire RxJS in Angular Deep Dive series. Remember: RxJS seems complex at first, but once you see the patterns - Observable → pipe → subscribe, BehaviorSubject in services, async pipe in templates - everything clicks. The best way to get better is to build. Take a real project and apply what you've learned!
Comments
No comments yet. Start the discussion.