DEV Community

Cover image for RxJS in Angular — Chapter 10 (Final) | Real-World Patterns, Best Practices & Everything That Actually Matters
Jack Pritom Soren
Jack Pritom Soren

Posted on

RxJS in Angular — Chapter 10 (Final) | Real-World Patterns, Best Practices & Everything That Actually Matters

👋 Welcome to the Final Chapter!

You've made it. 🎉

Over the past 9 chapters, you've learned Observables, subscribe, pipe, map, filter, tap, switchMap, mergeMap, concatMap, Subject, BehaviorSubject, error handling, combining operators, forms, and timing operators.

Now let's put it ALL together. This chapter is about real patterns you'll use every single day in production Angular apps — and the mistakes you must avoid.


đŸ—ī¸ Pattern 1: 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
    });
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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>();
}
Enter fullscreen mode Exit fullscreen mode

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[]>())
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

🛑 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));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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'))
])
Enter fullscreen mode Exit fullscreen mode

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))
)
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

📋 The Complete RxJS Cheat Sheet

Creating Observables

  • of(1,2,3) — from static values
  • from([1,2,3]) — from array or Promise
  • interval(ms) — ticks every N ms
  • timer(ms) — emits once after delay
  • fromEvent(el, 'click') — from DOM event
  • new Observable(observer => ...) — custom

Transforming

  • map(fn) — transform each value
  • filter(fn) — keep only matching values
  • tap(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 duplicates
  • take(n) — take first N, then complete
  • takeUntil(obs$) — take until signal

Combining

  • forkJoin({...}) — parallel, wait for all
  • combineLatest([...]) — emit on any change
  • withLatestFrom(obs$) — snapshot on trigger
  • zip(obs1, obs2) — pair by position

Error Handling

  • catchError(fn) — catch and recover
  • retry(n) — retry N times
  • finalize(fn) — always runs last

Subjects

  • Subject — multicast, no memory
  • BehaviorSubject(init) — remembers latest
  • ReplaySubject(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:

1. NgRx — Formal state management built on RxJS (for very large apps)

2. Angular Signals — Angular's newer reactivity system (complements RxJS)

3. shareReplay() — Share a single HTTP response between multiple subscribers

4. Custom Operators — Build your own reusable operators

5. Testing RxJS — Use TestScheduler and marble testing

6. 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 here.

Good luck, and happy coding! 🚀


💌 RxJS Deep Dive Newsletter Series | Chapter 10 of 10 — The Final Chapter

Share this series with your team! Every Angular developer needs to understand RxJS.

Top comments (0)