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

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 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!

Comments

No comments yet. Start the discussion.