Getter vs Signal Computed
A class getter is re-evaluated on every change detection cycle regardless of whether its dependencies changed. A computed() signal memoizes the result and only recalculates when a tracked signal dependency changes.
// Called on EVERY change detection cycle — even when tags never changed get hasFilterTag(): boolean { return this.tags.includes('filter'); // expensive if tags is large }
// Recalculates ONLY when filterTags signal changes protected readonly hasFilterTag = computed(() => this.filterTags().includes('filter'), );
Each CD trigger increments the getter call count. The computed re-evaluation count only increases when you "Toggle filter tag" - regardless of how many CD cycles occurred.
Lazy Loading Bundle size
loadChildren with NgModules requires shipping an entire module graph per route. loadComponent with standalone components produces a minimal, targeted chunk containing only that component and its direct imports - nothing more.
// Old approach — requires a full NgModule wrapper const routes: Routes = [{ path: 'dashboard', loadChildren: () => import('./dashboard/dashboard.module') .then(m => m.DashboardModule), }]; // DashboardModule must declare and export DashboardComponent, // pulling in the entire module graph even for a single page.
// Modern approach — load the standalone component directly const routes: Routes = [{ path: 'dashboard', loadComponent: () => import('./dashboard/dashboard.component') .then(m => m.DashboardComponent), }]; // No NgModule overhead. Angular splits exactly this component // into its own chunk — smaller, faster initial load.
An NgModule chunk includes every component declared in that module, whether the route uses them or not. Standalone lazy-loading lets the bundler apply tree-shaking at the component level - the chunk only contains what's directly imported by DashboardComponent. Typical savings: 20 - 60 KB per route for mid-size modules.
Signal Encapsulation Encapsulation
Exposing a WritableSignal publicly breaks encapsulation - any consumer can mutate state the service is supposed to own. Use .asReadonly() to expose a view-only projection while keeping mutation methods private to the service.
// Anti-pattern: exposing WritableSignal lets ANY consumer mutate state @Injectable({ providedIn: 'root' }) export class UserService { readonly user: WritableSignal<User> = signal(null); } // Any component can now do: this.userService.user.set({ name: 'Hacker' }); // uncontrolled mutation
// Service owns mutations internally; exposes only a readonly view @Injectable({ providedIn: 'root' }) export class UserService { private readonly _user = signal<User | null>(null); readonly user = this._user.asReadonly(); loadUser(id: string): void { // Only this service can set _user this._user.set(/* fetched data */); } }
Private _signal = signal() inside the service -> public signal = this._signal.asReadonly() for consumers. This is the same principle as a private setter with a public getter in traditional OOP, but enforced at the type level by Angular's signal API.
Service Scope DI
providedIn: 'root' creates a singleton that lives for the entire app lifetime. For ephemeral state - like a multi-step wizard - use component-level providers so the service is created fresh when the component mounts and garbage-collected when it is destroyed.
// providedIn: 'root' creates a singleton that lives for the entire app. // Fine for global services, but wrong for wizard/form state. @Injectable({ providedIn: 'root' }) export class WizardStateService { currentStep = signal(0); formData = signal({}); // Lives forever — state leaks between wizard sessions! }
// Component-level provider: created when component mounts, // destroyed when it unmounts. State resets automatically. @Component({ selector: 'app-wizard', providers: [WizardStateService], // scoped to this component subtree templateUrl: './wizard.component.html', styleUrl: './wizard.component.scss', }) export class WizardComponent { // WizardStateService is injected as a fresh instance here private readonly state = inject(WizardStateService); }
Component-scoped providers are tied to the component's destroy lifecycle. This means no manual reset logic, no stale state between wizard sessions, and automatic memory reclamation - Angular handles it for you. All child components of the wizard also receive the same scoped instance.
Input Debounce RxJS
Without debounceTime, every keystroke fires a network request. With debounceTime(300) + distinctUntilChanged(), requests only fire after the user pauses - and only when the value actually changed.
// Every keystroke fires an HTTP request — no throttle, no dedup onSearchInput(value: string): void { this.apiService.search(value).subscribe({ next: results => this.results.set(results), }); }
private readonly searchInput$ = new Subject<string>(); constructor() { this.searchInput$.pipe( debounceTime(300), // wait 300ms after last keystroke distinctUntilChanged(), // skip if value didn't change switchMap(q => this.api.search(q)), takeUntilDestroyed(), ).subscribe({ next: results => this.results.set(results), }); }
Start typing to see requests logged here...
track in @for Performance
Without a proper track expression, Angular cannot identify which items changed and re-processes all N views on every list mutation. With track item.id, only genuinely new or updated items trigger DOM mutations.
// ⌠track $index — position as identity @for (item of tasks(); track $index) { <app-task-card [task]="item" /> } // On prepend, shuffle or mid-list removal: // Angular re-processes ALL N views — O(n) DOM operations. // DOM state (typed inputs, animations) does not follow the item.
// ✅ track item.id — stable identity per item @for (item of tasks(); track item.id) { <app-task-card [task]="item" /> } // On prepend: Angular creates 1 new view — O(1) DOM creation. // On shuffle: reorders DOM nodes without re-rendering — O(n) moves. // DOM state correctly follows each item in both cases.
xN shows how many times each view was re-processed by Angular. On the track $index side, all items increment on every operation - even unchanged ones. On the track item.id side, only the new item starts at x1; existing items stay untouched.
If the list is order-stable (never reordered, items only appended at the end, never removed from the middle) and items carry no internal DOM state (inputs, animations), $index is fine. In any other scenario - shuffle, prepend, arbitrary removal, pagination - prefer track item.id or another stable identity property.
Observable Cleanup RxJS
Subscriptions that outlive their component cause memory leaks and ghost event handlers. Angular 16+ ships takeUntilDestroyed() as the canonical solution - no boilerplate Subject, no ngOnDestroy required.
// Manual unsubscribe — easy to forget, verbose private sub!: Subscription; ngOnInit(): void { this.sub = this.stream$.subscribe({ next: v => doWork(v) }); } ngOnDestroy(): void { this.sub.unsubscribe(); // easy to forget one }
Verbose. Easy to forget when there are multiple subscriptions.
// takeUntil pattern — better, but requires boilerplate Subject private readonly destroy$ = new Subject<void>(); ngOnInit(): void { this.stream$ .pipe(takeUntil(this.destroy$)) .subscribe({ next: v => doWork(v) }); } ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); }
Better - but still requires a Subject and a full ngOnDestroy lifecycle hook.
// takeUntilDestroyed (Angular 16+) — no Subject, no ngOnDestroy export class MyComponent { private readonly destroyRef = inject(DestroyRef); constructor() { // Works in inject() context — even outside ngOnInit this.stream$ .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: v => doWork(v) }); } }
Zero boilerplate. Works in constructors and inject() context. Angular 16+.
When called inside the constructor (or any function in the inject context), it automatically binds to the current component's DestroyRef without needing to pass it explicitly. You can also pass a destroyRef explicitly when calling from outside the inject context - for example, a standalone service method.
Observables returned by HttpClient are finite - they emit a single response and then complete automatically, releasing all resources without memory leaks. Applying takeUntilDestroyed to them is harmless, but unnecessary. Reserve cleanup for long-lived streams: interval(), fromEvent(), WebSocket connections, and manually managed Subjects.
@defer Blocks Angular 17+
Eagerly loaded components bloat the initial bundle even when the user never scrolls to them. @defer performs automatic code-splitting for the deferred component - no bundler config, no loadComponent, just a template declaration.
// ❌ Eager loading — full bundle sent to the client // even if the component is below the fold or rarely used <app-heavy-chart [data]="analytics()" /> <app-rich-text-editor [content]="post()" /> <app-data-grid [rows]="rows()" /> // All JS/CSS for these components is downloaded on initial load. // The user pays the cost even if they never scroll there.
// ✅ @defer — declarative lazy loading in the template @defer (on viewport) { <app-heavy-chart [data]="analytics()" /> } @placeholder { <div class="chart-skeleton">Loading chart…</div> } @loading (minimum 300ms) { <app-spinner /> } @error { <p>Failed to load the chart.</p> } // The heavy-chart JS is downloaded only when the block // enters the viewport — no extra bundling config required.
// Available triggers — can be combined @defer (on idle) { // when the browser is idle (requestIdleCallback) @defer (on viewport) { // when the placeholder enters the viewport @defer (on interaction) { // on click/focus on the placeholder @defer (on hover) { // on mouse hover over the placeholder @defer (on timer(2s)) { // after 2 seconds from render @defer (when isAdmin()) { // when the expression becomes truthy // Combination: idle AND viewport @defer (on idle; on viewport) { <app-analytics-dashboard /> } // Eager prefetch — downloads JS without rendering yet @defer (on interaction; prefetch on idle) { <app-rich-editor /> }
The Angular CLI detects components inside @defer and automatically moves them into separate chunks at build time. No extra configuration needed - the same mechanism used by loadComponent in routes, but applicable to any part of the template. The @placeholder block renders immediately; the main content is downloaded and rendered only when the trigger fires.
Components above the fold, critical for LCP (Largest Contentful Paint), or that require immediate SSR hydration should not be deferred. Reserve @defer for secondary content: charts, rich-text editors, data grids, inactive tabs, and anything below the initial visible screen.
OnPush Change Detection Performance
With the default strategy (Default), Angular checks the template of every component on each change detection cycle - even when nothing relevant changed. OnPush puts the component to sleep: it only re-checks when an @Input reference changes, a Signal emits, or the async pipe receives a new value.
// ❌ Default — component re-checks on EVERY CD cycle @Component({ selector: 'app-dashboard', standalone: true, // changeDetection not set → Default is implicit templateUrl: './dashboard.component.html', }) export class DashboardComponent { @Input() title = ''; // Re-checks bindings even when title hasn't changed. // A setTimeout in a parent component already triggers this. }
// ✅ OnPush — re-renders only when necessary @Component({ selector: 'app-dashboard', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './dashboard.component.html', }) export class DashboardComponent { @Input() title = ''; // ✓ re-renders if reference changes count = signal(0); // ✓ re-renders when signal changes data$ = this.svc.data$; // ✓ re-renders via async pipe // Anything else → component stays dormant. }
Press Trigger external CD several times and notice OnPush renders stays at zero - the component literally skips all binding checks.
With Signals, Angular knows exactly which node in the template changed and updates only that DOM node, without traversing the entire component tree. Declaring OnPush formalizes this in the component's CD strategy, eliminating unnecessary checks even in Zone.js apps. In components built exclusively on Signals, Angular can eventually skip CD entirely (Zoneless Architecture - Angular 18+).
Forms Strategy Bonus
Use Reactive Forms for complex, dynamic, or programmatically validated forms - validation lives in TypeScript, is easy to test, and composes well. Use Template-driven (ngModel) only for simple, isolated inputs where no cross-field validation or dynamic control generation is needed.
// Template-driven for complex validation — hard to test, hard to compose <form #f="ngForm" (ngSubmit)="submit(f)"> <input name="email" [(ngModel)]="email" required email #emailCtrl="ngModel"> <span *ngIf="emailCtrl.invalid && emailCtrl.touched"> Invalid email <!-- validation logic hidden in template --> </span> </form>
// Reactive form: validation in TypeScript, easy to test and compose protected readonly form = this.fb.group({ email: ['', [Validators.required, Validators.email]], username: ['', [Validators.required, Validators.minLength(3)]], }); // Validation is explicit, type-safe, and trivially unit-testable. // Use ngModel only for simple, standalone inputs.
NgOptimizedImage Performance
A plain <img src> has no lazy loading, no intrinsic dimensions, and no fetch-priority hint — all of which hurt LCP and cause layout shift. The NgOptimizedImage directive fixes all three automatically.
// Plain img — no lazy loading, no size hints, hurts LCP <img src="/hero.jpg" alt="Hero banner"> // Browser fetches immediately (no priority control), // no dimensions → layout shift (CLS).
// NgOptimizedImage — lazy, sized, priority-aware <img ngSrc="/hero.jpg" width="800" height="600" priority> // LCP images: fetchpriority="high" + preload link. // Others: loading="lazy". width/height prevent CLS.
Open DevTools → Elements and compare both sides. The plain images lack loading, width/height, and fetchpriority. The optimized images have loading="lazy" on below-fold images, fetchpriority="high" on the priority one (preventing layout shift and improving LCP).
Virtual Scroll Performance
Rendering thousands of items in the DOM at once causes massive layout thrashing and high memory usage. CDK cdk-virtual-scroll-viewport keeps only the visible rows in the DOM — list size no longer affects performance.
// Renders ALL rows in the DOM immediately @for (item of items; track item.id) { <div class="row">{{ item.label }}</div> } // 1 000 items → 1 000 DOM nodes in memory at once
// CDK Virtual Scroll — only visible rows in DOM <cdk-virtual-scroll-viewport itemSize="40"> <div *cdkVirtualFor="let item of items"> {{ item.label }} </div> </cdk-virtual-scroll-viewport> // 1 000 items → ~10 DOM nodes regardless of size
Open DevTools → Elements and count list rows on each side. The left renders 200 nodes eagerly (a real app with 1 000 would lock the browser). The CDK side renders only ~10 DOM nodes regardless of list length — scroll to item #1 000 instantly with no performance cost.
Pure Pipe Performance
Calling a method directly in a template expression re-executes it on every change detection cycle. A pure: true pipe is memoized by Angular — it only re-runs when the input reference changes.
// Called on EVERY change detection cycle <span>{{ formatPrice(item.price) }}</span> formatPrice(price: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(price); }
// Pure pipe — Angular memoizes by input reference @Pipe({ name: 'formatPrice', pure: true }) export class FormatPricePipe { transform(price: number): string { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(price); } }
Press Trigger CD: the method is called 5 times (once per product per cycle) while the pipe call count stays at zero — prices did not change so Angular skips re-evaluation. Press Change price: only 1 new pipe call fires (the changed item). The method always fires 5 times.
toSignal() Signals
toSignal() converts any Observable into a readable Signal. It automatically subscribes in the injection context and unsubscribes when the component is destroyed — eliminating manual subscription boilerplate.
// Manual subscribe — boilerplate, easy to leak export class ItemsComponent { items: Item[] = []; constructor() { inject(HttpClient).get<Item[]>('/api/items') .pipe(takeUntilDestroyed()) .subscribe(data => this.items = data); } }
// toSignal — one line, auto-unsubscribes, signal-reactive export class ItemsComponent { readonly items = toSignal( inject(HttpClient).get<Item[]>('/api/items'), { initialValue: [] }, ); }
effect() vs computed() Signals
Use computed() to derive values from signals — it is synchronous, memoized, and tracked. Use effect() only for side-effects such as logging, analytics, or DOM manipulation. Writing to a signal inside effect() to compute a value is an anti-pattern.
// Wrong: effect() to derive state — async, causes extra render readonly fullName = signal(''); effect(() => { this.fullName.set( this.first() + ' ' + this.last() ); }, { allowSignalWrites: true }); // ← flag required
// computed() — synchronous, memoized, no extra render readonly fullName = computed(() => this.first() + ' ' + this.last() ); // effect() only for side-effects (logging, DOM sync, analytics) effect(() => console.log('Name:', this.fullName()));
Change a name to observe evaluation order...
Each name change: computed() evaluates once, synchronously, during rendering. The effect() runs after the render and writes to another signal — causing Angular to schedule a second render. Watch the Renders counter climb roughly twice as fast as Computed evaluations.
linkedSignal() Signals
linkedSignal() creates a signal that derives its default from another signal but remains writable. When the source changes, it resets to the new derived value — eliminating the verbose pattern of a separate signal plus an effect() to sync them.
// Two separate signals + effect to sync — verbose, race-prone readonly options = signal<string[]>([]); readonly selected = signal<string | null>(null); effect(() => { this.selected.set(this.options()[0] ?? null); });
// linkedSignal — derived but writable; resets when source changes readonly options = signal<string[]>([]); readonly selected = linkedSignal(() => this.options()[0]); // User can override: selected.set('other') // When options changes, selected resets to options()[0]
Signal Input Signals
The @Input() decorator is not reactive — you need ngOnChanges to react to changes. The input() function returns a signal, making the input directly composable with computed() and effect() without any lifecycle hook.
// @Input decorator — not reactive, needs ngOnChanges @Input() title: string = ''; @Input() userId: number = 0; ngOnChanges(): void { // re-derive state on every input change manually this.label = this.title.toUpperCase(); }
// Signal input — reactive, composable with computed() readonly title = input<string>(''); readonly userId = input<number>(0); // Derived state updates automatically — no lifecycle hook readonly label = computed(() => this.title().toUpperCase());
Facade Pattern Architecture
A component that injects multiple unrelated services becomes tightly coupled to every one of them. A Facade service aggregates and orchestrates those services internally — the component depends on a single, stable API.
// Component knows too much — coupled to 3 services export class CheckoutComponent { private cart = inject(CartService); private user = inject(UserService); private inv = inject(InventoryService); checkout() { this.inv.reserve(this.cart.items()); this.cart.clear(); this.user.addOrder(this.cart.total()); } }
// Component depends on one facade — orchestration is encapsulated export class CheckoutComponent { private facade = inject(CheckoutFacade); checkout() { this.facade.checkout(); } } // CheckoutFacade orchestrates Cart, User, Inventory internally
Smart / Dumb Components Architecture
Smart (container) components fetch data and manage state. Dumb (presentational) components receive data via input() and emit events via output() — they are stateless, easily testable, and reusable.
// "Dumb" component that fetches its own data — hard to test export class UserCardComponent { user = toSignal(inject(HttpClient) .get<User>('/api/me')); // fetches on every instantiation, untestable in isolation }
// Smart parent fetches; dumb child only renders // Smart (container): readonly user = toSignal(this.http.get<User>('/api/me')); // template: <app-user-card [user]="user()" /> // Dumb (presentational): readonly user = input<User | undefined>(); // pure display — no HTTP, easily unit-tested
InjectionToken Architecture
Hardcoding environment.apiUrl directly in a service creates an untestable, unswappable dependency. An InjectionToken makes the value part of Angular's DI graph — trivially overridable in tests and per-environment configurations.
// Hardcoded env reference — untestable, not swappable @Injectable({ providedIn: 'root' }) export class ApiService { private base = environment.apiUrl; getUsers() { return this.http.get(`${this.base}/users`); } }
// InjectionToken — testable, overridable per environment export const API_URL = new InjectionToken<string>('API_URL', { factory: () => inject(ENVIRONMENT).apiUrl, }); export class ApiService { private base = inject(API_URL); }
Folder Structure Architecture
Organizing by type (components/, services/, pipes/) mixes unrelated features and makes it hard to reason about or delete a feature. Organizing by feature keeps everything cohesive — each folder is self-contained and independently removable.
src/app/ ├── components/ │ ├── header.component.ts │ ├── user-card.component.ts │ └── product-card.component.ts ├── services/ │ ├── auth.service.ts │ └── cart.service.ts └── pipes/ └── format-price.pipe.ts
src/app/ ├── feature/ │ ├── auth/ │ │ ├── auth.service.ts │ │ └── login.component.ts │ └── cart/ │ ├── cart.service.ts │ └── cart.component.ts └── shared/ └── format-price.pipe.ts
bypassSecurityTrust Security
Calling bypassSecurityTrustHtml(userInput) disables Angular's built-in XSS sanitizer. Use [innerHTML] directly for user-provided content — Angular sanitizes it automatically. Only bypass sanitization for HTML you generate and control on the server.
// XSS risk — bypasses Angular's sanitizer entirely export class CommentComponent { private sanitizer = inject(DomSanitizer); safeHtml = this.sanitizer .bypassSecurityTrustHtml(userInput); // if userInput contains <script>, it will execute }
// Angular auto-sanitizes [innerHTML] — safe by default <div [innerHTML]="userContent"></div> // Only bypass for HTML sourced from your own trusted server: safeHtml = this.sanitizer .bypassSecurityTrustHtml(serverRenderedHtml); // NEVER call this with direct user input
HttpClient Generics TypeScript
Calling http.get() without a type parameter returns Observable<Object> — all type safety is lost and consumers must cast manually. Providing the generic http.get<User[]>() gives you full IntelliSense, compile-time checks, and safe refactoring.
// Returns Observable<Object> — no type safety getUsers() { return this.http.get('/api/users'); // consumers must cast: (response as User[]) // typos in property names compile with no error }
// Returns Observable<User[]> — fully typed getUsers(): Observable<User[]> { return this.http.get<User[]>('/api/users'); // IntelliSense, refactoring, and compile-time checks work }
output() Function TypeScript
The @Output() decorator with EventEmitter is the classic approach but is verbose and class-based. The output() function is the modern equivalent — no decorator, no import of EventEmitter, and it pairs naturally with signal inputs.
// Decorator-based — verbose, class-based EventEmitter @Output() selected = new EventEmitter<Item>(); @Output() removed = new EventEmitter<string>(); select(item: Item) { this.selected.emit(item); }
// Function-based output — no decorator, no EventEmitter import readonly selected = output<Item>(); readonly removed = output<string>(); select(item: Item) { this.selected.emit(item); }
Type Guards TypeScript
Using any silences the TypeScript compiler entirely — property access is unchecked and runtime crashes go undetected at compile time. unknown forces you to narrow the type before use, keeping the type system engaged and your code safe.
// any disables all type checking — unsafe property access function process(value: any) { console.log(value.name); // no error even if undefined value.nonExistent(); // runtime crash, not compile error }
// unknown forces explicit narrowing before use function isUser(v: unknown): v is User { return typeof v === 'object' && v !== null && 'name' in v; } function process(value: unknown) { if (isUser(value)) console.log(value.name); // safe }
inject() Function DX
Constructor injection requires listing all dependencies as constructor parameters — verbose and hard to refactor. The inject() function declares dependencies as class fields, is easier to read, and also works in standalone functions and composable helpers outside the class.
// Constructor injection — all deps in one long signature constructor( private svc: MyService, private router: Router, private http: HttpClient, ) {}
// inject() — field-level, composable, works in functions private readonly svc = inject(MyService); private readonly router = inject(Router); private readonly http = inject(HttpClient); // Also usable in standalone functions / composable helpers
Route Input Binding DX
Reading route parameters via ActivatedRoute.snapshot is imperative and verbose. With withComponentInputBinding() enabled in the router config, Angular binds route params, query params, and resolve data directly to input() signals — zero boilerplate.
// Manual ActivatedRoute snapshot — verbose, imperative export class DetailComponent { private route = inject(ActivatedRoute); id!: string; ngOnInit() { this.id = this.route.snapshot.params['id']; } }
// app.config.ts: provideRouter(routes, withComponentInputBinding()) // Route: { path: 'detail/:id', component: DetailComponent } export class DetailComponent { // Angular binds :id param automatically — no ActivatedRoute readonly id = input<string>(); }
bootstrapApplication() DX
The classic AppModule with @NgModule({ bootstrap }) is boilerplate that serves no purpose in an all-standalone app. bootstrapApplication() bootstraps the root component directly with a flat providers array — no module wrapper needed.
// NgModule boilerplate — required in Angular <14 @NgModule({ declarations: [AppComponent], imports: [BrowserModule, HttpClientModule], bootstrap: [AppComponent], }) export class AppModule {}
// Standalone bootstrap — no NgModule at all bootstrapApplication(AppComponent, { providers: [ provideRouter(routes), provideHttpClient(), provideAnimations(), ], });
RxJS Flattening Operators RxJS
Choosing the wrong flattening operator silently breaks behavior. switchMap cancels the previous inner observable — ideal for search, catastrophic for saves. concatMap queues emissions — correct for ordered writes. mergeMap runs concurrently — good for parallel reads.
// switchMap for a save — cancels in-flight requests! saveBtn$.pipe( switchMap(() => this.http.post('/save', data)), ).subscribe(); // If the user clicks twice, the first POST is cancelled // → data may never be persisted
// mergeMap — concurrent, good for parallel reads ids$.pipe( mergeMap(id => this.http.get(`/item/${id}`)), ).subscribe(); // All requests run in parallel — order not guaranteed // Ideal for independent, parallel fetches
// switchMap for search; concatMap for ordered writes search$.pipe( switchMap(q => this.http.get(`/search?q=${q}`)), ).subscribe(); // cancels stale searches ✓ saveBtn$.pipe( concatMap(() => this.http.post('/save', data)), ).subscribe(); // queues saves, none are lost ✓
combineLatest / withLatestFrom RxJS
Nesting subscribe() inside subscribe() creates callback hell, memory leaks, and impossible cleanup. combineLatest() emits whenever any source emits. withLatestFrom() samples a secondary stream only when the primary emits — both are flat and composable.
// Nested subscribes — callback hell, hard to clean up this.user$.subscribe(user => { this.prefs$.subscribe(prefs => { // inner subscription leaks if outer completes first this.render(user, prefs); }); });
// combineLatest — emits whenever either source emits combineLatest([this.user$, this.prefs$]).pipe( takeUntilDestroyed(), ).subscribe(([user, prefs]) => this.render(user, prefs)); // withLatestFrom — only user$ drives; prefs sampled silently this.user$.pipe(withLatestFrom(this.prefs$));
Higher-Order Observables RxJS
Subscribing inside a subscribe() callback creates deeply nested, unmanageable code with no way to cancel inner subscriptions when the outer completes. Higher-order operators like switchMap flatten the stream into a single subscription that is automatically managed.
// Nested subscribes — callback hell, impossible to cancel this.route.params.subscribe(params => { this.http.get(`/user/${params['id']}`) .subscribe(user => { // inner sub leaks; no way to cancel outer cleanly this.user = user; }); });
// Flat pipeline — single subscription, auto-cancels inner this.route.params.pipe( switchMap(params => this.http.get(`/user/${params['id']}`) ), takeUntilDestroyed(), ).subscribe(user => this.user = user);