DEV Community

Mahendranath Reddy
Mahendranath Reddy

Posted on

Angular Senior Developer — Interview Questions : Change Detection, Signals, RxJS, Architecture, Performance, and Testing with production

Angular Senior Developer — Interview Questions & Detailed Solutions

Table of Contents

Change Detection

  1. What is Zone.js and how does Angular use it?
  2. OnPush change detection strategy
  3. Signals vs Zone.js change detection
  4. detach() vs markForCheck()

Signals

  1. signal(), computed(), effect() primitives
  2. Signal and RxJS interop
  3. Signal inputs and model()
  4. Zoneless Angular
  5. Signals with OnPush

Performance

  1. trackBy and @for track
  2. Lazy loading strategies
  3. Non-destructive hydration
  4. Bundle size analysis
  5. NgOptimizedImage
  6. @defer triggers and states

RxJS

  1. switchMap vs mergeMap vs concatMap vs exhaustMap
  2. Avoiding memory leaks
  3. Error handling without killing a stream
  4. Custom RxJS operators
  5. Subject variants
  6. Async pipe advantages

Architecture

  1. Hierarchical dependency injection
  2. Angular 17+ architecture without NgModules
  3. Signals vs NgRx for state management
  4. Functional HTTP interceptors
  5. Micro frontends with Module Federation
  6. Functional route guards
  7. Pure vs impure pipes
  8. InjectionToken for configuration
  9. Multi-slot content projection
  10. Dynamic component creation
  11. Route resolve data strategy
  12. Structural vs attribute directives
  13. SSR and TransferState

Testing

  1. TestBed configuration
  2. Testing with HTTP dependencies
  3. Testing Signal-based components
  4. E2E testing strategy
  5. Component Harnesses
  6. Testing tools beyond TestBed

Change Detection


1. What is Zone.js and how does Angular use it for change detection?

Difficulty: Senior

Core Concept

Zone.js is a library that monkey-patches browser async APIs — setTimeout, Promise, fetch, addEventListener, XHR — to intercept and track asynchronous operations. Angular uses it to know when something might have changed in the application state so it can run change detection.

When any async operation completes inside Angular's zone (NgZone), it calls ApplicationRef.tick(), which runs change detection from the root component downward through the entire component tree.

How It Works Internally

User Event / setTimeout / HTTP Response
           ↓
       Zone.js intercepts
           ↓
     NgZone.onMicrotaskEmpty fires
           ↓
   ApplicationRef.tick() called
           ↓
   Change detection runs root → leaf
Enter fullscreen mode Exit fullscreen mode

Practical Code

import { Component, NgZone, OnInit } from '@angular/core';

@Component({
  selector: 'app-performance',
  template: `<canvas #chart></canvas><p>{{ frameCount }}</p>`
})
export class PerformanceComponent implements OnInit {
  frameCount = 0;

  constructor(private ngZone: NgZone) {}

  ngOnInit() {
    // Running outside Angular zone — NO change detection triggered
    // Perfect for animations, third-party charting libraries, WebGL
    this.ngZone.runOutsideAngular(() => {
      let rafId: number;
      const loop = () => {
        this.frameCount++;          // updates the variable
        // but Angular doesn't know — no change detection
        rafId = requestAnimationFrame(loop);
      };
      loop();
    });

    // Re-enter the zone only when the user needs to see an update
    setTimeout(() => {
      this.ngZone.run(() => {
        // Now Angular detects the change and re-renders
        console.log('Frames rendered:', this.frameCount);
      });
    }, 1000);
  }
}
Enter fullscreen mode Exit fullscreen mode

When Zone.js Is Problematic

// Every setInterval triggers full tree change detection
setInterval(() => {
  this.liveData = this.sensor.read(); // expensive if tree is large
}, 16); // 60fps = 60 CD cycles/sec across ALL components

// Solution: run outside zone, manually trigger when needed
this.ngZone.runOutsideAngular(() => {
  setInterval(() => {
    this.liveData = this.sensor.read();
    if (this.liveData.isAlarm) {
      this.ngZone.run(() => this.showAlarm()); // only re-enter when critical
    }
  }, 16);
});
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls

  • Third-party libraries (D3, Socket.IO, Google Maps) run inside the zone by default, triggering excessive change detection
  • Promise chains inside components all trigger CD even if state didn't change
  • isStable observable on NgZone is useful for detecting when the app has settled (e.g., for SSR)

Follow-up Talking Points

  • Zone.js is being phased out in favour of Signals + zoneless Angular
  • NgZone.isInAngularZone() can help debug unexpected CD triggers
  • ngZone.runOutsideAngular() + explicit ngZone.run() is the performance pattern for real-time data

2. Explain OnPush change detection strategy

Difficulty: Senior

Core Concept

ChangeDetectionStrategy.OnPush tells Angular to skip change detection on a component unless one of four conditions is met:

  1. An @Input() reference changes (shallow comparison — not deep mutation)
  2. An event originates from the component or its children
  3. An Observable bound with the async pipe emits a new value
  4. ChangeDetectorRef.markForCheck() is called manually

This is the single biggest performance win in most Angular apps because it prevents unnecessary traversal of stable component subtrees.

Code Example

import {
  Component,
  Input,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  OnInit
} from '@angular/core';
import { WebSocketService } from './ws.service';

interface User {
  id: number;
  name: string;
  avatar: string;
}

@Component({
  selector: 'app-user-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <img [src]="user.avatar" />
      <h3>{{ user.name }}</h3>
      <span>{{ lastSeen }}</span>
    </div>
  `
})
export class UserCardComponent implements OnInit {
  @Input() user!: User;
  lastSeen = 'just now';

  constructor(
    private cdr: ChangeDetectorRef,
    private ws: WebSocketService
  ) {}

  ngOnInit() {
    // External update — not from an input or DOM event
    // Without markForCheck(), the template would not update
    this.ws.presenceFor(this.user.id).subscribe(time => {
      this.lastSeen = time;
      this.cdr.markForCheck(); // schedule CD for this component + ancestors
    });
  }
}

// ✅ CORRECT — new object reference triggers OnPush
updateUser(newName: string) {
  this.user = { ...this.user, name: newName }; // spread creates new reference
}

// ❌ WRONG — mutation doesn't trigger OnPush
updateUser(newName: string) {
  this.user.name = newName; // same reference — OnPush skips this component
}
Enter fullscreen mode Exit fullscreen mode

Immutability Pattern with OnPush

// Use readonly interfaces to enforce immutability
interface ReadonlyUser {
  readonly id: number;
  readonly name: string;
  readonly settings: ReadonlySettings;
}

// Service returns new references
@Injectable({ providedIn: 'root' })
export class UserService {
  private state = signal<ReadonlyUser | null>(null);

  updateName(name: string) {
    this.state.update(u => u ? { ...u, name } : null); // always new reference
  }
}
Enter fullscreen mode Exit fullscreen mode

OnPush Decision Tree

Should I use OnPush?
├── Does the component have @Input() data? → YES, use OnPush
├── Is state managed via Observables / Signals? → YES, use OnPush
├── Does the component rely on global mutable state? → Review design first
└── Is it a leaf component with no children? → Especially YES
Enter fullscreen mode Exit fullscreen mode

Follow-up Talking Points

  • All new components should use OnPush by default — it should be the team convention
  • With Angular Signals, OnPush becomes even more granular: only the exact component reading a signal gets re-checked
  • ChangeDetectorRef.detach() is more aggressive than OnPush — it completely removes the component from CD until you call detectChanges() manually

3. How do Angular Signals differ from Zone.js-based change detection?

Difficulty: Advanced

Core Concept

Aspect Zone.js Signals
Trigger Any async operation Signal value change
Scope Full component tree Only consumers of that signal
Timing Async (microtask queue) Synchronous
Requires Zone.js Yes No
Memoization No Yes (computed)
Tree-shaking N/A Better (no Zone.js bundle)

Zone.js uses a "push everything, check everything" model. Signals use a "pull what you need, update exactly who cares" model — a dependency graph.

Signals in Depth

import { Component, signal, computed, effect, inject } from '@angular/core';
import { ProductService } from './product.service';

@Component({
  selector: 'app-shop',
  template: `
    <div>Items: {{ cartCount() }}</div>
    <div>Total: {{ formattedTotal() }}</div>
    <div>Empty: {{ isEmpty() }}</div>
  `
})
export class ShopComponent {
  private productService = inject(ProductService);

  // --- Writable signals ---
  cartItems = signal<CartItem[]>([]);
  discount = signal(0);

  // --- Computed (derived, memoized, lazy) ---
  cartCount = computed(() => this.cartItems().length);

  subtotal = computed(() =>
    this.cartItems().reduce((sum, item) => sum + item.price * item.qty, 0)
  );

  total = computed(() => {
    const sub = this.subtotal();
    const disc = this.discount();
    return sub - (sub * disc / 100);
    // Only re-computes when cartItems OR discount changes
  });

  formattedTotal = computed(() =>
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
      .format(this.total())
  );

  isEmpty = computed(() => this.cartItems().length === 0);

  constructor() {
    // --- Effect: run side effects reactively ---
    effect(() => {
      // Automatically tracks: cartItems and total
      const count = this.cartCount();
      const amount = this.total();
      // Re-runs whenever either signal changes
      localStorage.setItem('cart', JSON.stringify({ count, amount }));
      console.log(`Cart updated: ${count} items, $${amount}`);
    });
  }

  addItem(item: CartItem) {
    // Signals are synchronous — update is immediate
    this.cartItems.update(items => [...items, item]);
    // Angular schedules a re-render for components reading cartItems/cartCount/total
    // Only those components — not the whole tree
  }
}
Enter fullscreen mode Exit fullscreen mode

Signal Update Methods

const count = signal(0);

// .set() — replace the value entirely
count.set(5);

// .update() — derive new value from old value
count.update(v => v + 1);

// .mutate() was removed in Angular 17 — use update() with spread instead
// For objects:
const user = signal({ name: 'Alice', age: 30 });
user.update(u => ({ ...u, age: 31 }));      // ✅ immutable update
Enter fullscreen mode Exit fullscreen mode

Why Signals Are Better for Performance

// Zone.js scenario:
// - 100 components in tree
// - setInterval fires → CD runs ALL 100 checks
// - Even if only 1 component's data changed

// Signals scenario:
// - 100 components in tree
// - Signal A changes → only 3 components read Signal A → only 3 re-render
// - Precise, surgical updates
Enter fullscreen mode Exit fullscreen mode

4. When would you use detach() vs markForCheck() on ChangeDetectorRef?

Difficulty: Expert

Core Concept

Method Effect Use Case
markForCheck() Marks component + ancestors dirty, schedules next CD External async updates in OnPush components
detach() Removes component from CD tree entirely High-frequency rendering you fully control
detectChanges() Synchronously runs CD on this component + children Manual rendering after detach()
reattach() Re-adds component to automatic CD Restore normal CD after detach()

detach() Pattern — High-Frequency Real-Time Data

import {
  Component, ChangeDetectorRef, ChangeDetectionStrategy,
  OnInit, OnDestroy, ElementRef, ViewChild
} from '@angular/core';
import { WebSocketService } from './ws.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-live-chart',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<canvas #canvas width="800" height="300"></canvas>`
})
export class LiveChartComponent implements OnInit, OnDestroy {
  @ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
  private sub!: Subscription;
  private buffer: number[] = [];
  private frameId!: number;
  private lastRenderTime = 0;

  constructor(
    private cdr: ChangeDetectorRef,
    private ws: WebSocketService
  ) {
    // Fully opt out of automatic change detection
    this.cdr.detach();
  }

  ngOnInit() {
    // Data arrives 60x/second but we render at 30fps max
    this.sub = this.ws.priceStream$.subscribe(price => {
      this.buffer.push(price);
    });

    this.startRenderLoop();
  }

  private startRenderLoop() {
    this.frameId = requestAnimationFrame(() => {
      const now = performance.now();
      if (now - this.lastRenderTime >= 33) { // ~30fps
        if (this.buffer.length > 0) {
          this.drawChart(this.buffer);
          this.buffer = [];
          this.cdr.detectChanges(); // manually render when needed
          this.lastRenderTime = now;
        }
      }
      this.startRenderLoop();
    });
  }

  private drawChart(data: number[]) {
    const ctx = this.canvasRef.nativeElement.getContext('2d')!;
    // Direct Canvas API — no Angular binding needed
    ctx.clearRect(0, 0, 800, 300);
    // ... draw lines
  }

  ngOnDestroy() {
    cancelAnimationFrame(this.frameId);
    this.sub.unsubscribe();
    this.cdr.reattach(); // clean up — restore CD
  }
}
Enter fullscreen mode Exit fullscreen mode

markForCheck() Pattern — External Data Sources

@Component({
  selector: 'app-notifications',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (n of notifications; track n.id) {
      <div class="notification">{{ n.message }}</div>
    }
  `
})
export class NotificationsComponent implements OnInit {
  notifications: Notification[] = [];

  constructor(
    private cdr: ChangeDetectorRef,
    private pushService: PushNotificationService
  ) {}

  ngOnInit() {
    // Service Worker push event — completely outside Angular zone
    this.pushService.onMessage$.subscribe(notification => {
      this.notifications = [notification, ...this.notifications].slice(0, 10);

      // markForCheck() tells Angular: "this component needs checking on next CD cycle"
      // It doesn't run CD immediately — it schedules it
      this.cdr.markForCheck();
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Difference Visualised

markForCheck():
  Component → Parent → GrandParent → Root
  [All marked dirty, CD runs from root on next tick]

detach():
  Component is completely removed from CD tree
  [Must call detectChanges() yourself, synchronously]
Enter fullscreen mode Exit fullscreen mode

Signals


5. What are the core Signal primitives?

Difficulty: Senior

signal() — Writable Reactive State

import { signal } from '@angular/core';

// Primitive values
const count = signal(0);
const name = signal('Alice');
const isLoading = signal(false);

// Objects and arrays
const user = signal<User | null>(null);
const items = signal<Product[]>([]);

// Reading (always call as function)
console.log(count()); // 0
console.log(name());  // 'Alice'

// Writing
count.set(5);           // set absolute value
count.update(v => v+1); // derive from current value
name.set('Bob');

// Typed signals
const status = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
Enter fullscreen mode Exit fullscreen mode

computed() — Derived State

import { signal, computed } from '@angular/core';

const price = signal(100);
const quantity = signal(3);
const taxRate = signal(0.08);

// Lazy: only recalculates when dependencies change
const subtotal = computed(() => price() * quantity());
const tax = computed(() => subtotal() * taxRate());
const total = computed(() => subtotal() + tax());
const formattedTotal = computed(() =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(total())
);

// computed is read-only — cannot call .set() on it
// formattedTotal.set('$100'); // ❌ TypeScript error

// Conditional computed — dependencies tracked dynamically
const theme = signal<'light' | 'dark'>('light');
const bgColor = computed(() => theme() === 'light' ? '#ffffff' : '#1a1a1a');
// Angular only tracks signals actually called during evaluation
Enter fullscreen mode Exit fullscreen mode

effect() — Side Effects

import { Component, signal, computed, effect } from '@angular/core';

@Component({ selector: 'app-theme', template: '...' })
export class ThemeComponent {
  theme = signal<'light' | 'dark'>('light');
  fontSize = signal(16);

  constructor() {
    // Runs immediately on creation, then whenever theme OR fontSize changes
    effect(() => {
      document.documentElement.setAttribute('data-theme', this.theme());
      document.documentElement.style.fontSize = this.fontSize() + 'px';
      localStorage.setItem('theme', this.theme());
    });

    // effect with cleanup — for subscriptions, listeners, timers
    effect((onCleanup) => {
      const handler = (e: MediaQueryListEvent) => {
        this.theme.set(e.matches ? 'dark' : 'light');
      };
      const mq = window.matchMedia('(prefers-color-scheme: dark)');
      mq.addEventListener('change', handler);

      onCleanup(() => mq.removeEventListener('change', handler));
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

untracked() — Escape Hatch

import { signal, computed, untracked } from '@angular/core';

const a = signal(1);
const b = signal(2);

// computed only depends on 'a' — b changes won't trigger recalculation
const result = computed(() => {
  const aVal = a();
  const bVal = untracked(() => b()); // read b without creating dependency
  return aVal + bVal;
});
Enter fullscreen mode Exit fullscreen mode

6. How do you interop between Signals and RxJS Observables?

Difficulty: Senior

toSignal() — Observable → Signal

import { Component, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

@Component({
  selector: 'app-search',
  template: `
    <input (input)="query.set($any($event.target).value)" [value]="query()" />

    @if (isLoading()) {
      <app-spinner />
    }

    @for (result of results(); track result.id) {
      <app-result-card [result]="result" />
    } @empty {
      <p>No results for "{{ query() }}"</p>
    }
  `
})
export class SearchComponent {
  private http = inject(HttpClient);

  query = signal('');

  // toSignal: subscribes in injection context, auto-unsubscribes on destroy
  results = toSignal(
    toObservable(this.query).pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(q => q.length > 2
        ? this.http.get<SearchResult[]>(`/api/search?q=${q}`)
        : of([])
      )
    ),
    { initialValue: [] as SearchResult[] }
  );

  // requireSync: use when Observable emits synchronously (BehaviorSubject, etc.)
  private authService = inject(AuthService);
  currentUser = toSignal(this.authService.user$, { requireSync: true });
}
Enter fullscreen mode Exit fullscreen mode

toObservable() — Signal → Observable

import { toObservable } from '@angular/core/rxjs-interop';
import { Component, signal, inject } from '@angular/core';

@Component({...})
export class PaginatedListComponent {
  pageSize = signal(10);
  pageIndex = signal(0);

  // Convert multiple signals into an Observable stream for complex pipelines
  private params$ = combineLatest([
    toObservable(this.pageSize),
    toObservable(this.pageIndex),
  ]).pipe(
    map(([size, index]) => ({ pageSize: size, pageIndex: index }))
  );

  data = toSignal(
    this.params$.pipe(
      switchMap(params => this.dataService.load(params))
    ),
    { initialValue: [] }
  );

  nextPage() { this.pageIndex.update(i => i + 1); }
  prevPage() { this.pageIndex.update(i => Math.max(0, i - 1)); }
}
Enter fullscreen mode Exit fullscreen mode

Full Bidirectional Pattern

@Injectable({ providedIn: 'root' })
export class HybridStore {
  // Start with RxJS for compatibility with existing code
  private items$ = new BehaviorSubject<Item[]>([]);

  // Expose as Signal for modern components
  items = toSignal(this.items$, { requireSync: true });

  // Keep RxJS Observable for services that need it
  itemsObservable = this.items$.asObservable();

  add(item: Item) {
    this.items$.next([...this.items$.value, item]);
  }
}
Enter fullscreen mode Exit fullscreen mode

7. What are signal inputs and two-way bindable model() signals?

Difficulty: Advanced

input() — Signal Inputs

import { Component, input, computed } from '@angular/core';

@Component({
  selector: 'app-badge',
  template: `
    <span [class]="classes()" [style.font-size.px]="size()">
      {{ label() }}
    </span>
  `
})
export class BadgeComponent {
  // Required input — TypeScript error if parent doesn't provide it
  label = input.required<string>();

  // Optional input with default value
  size = input(14);
  variant = input<'primary' | 'success' | 'danger'>('primary');
  rounded = input(false);

  // Derived from inputs — reactive, no ngOnChanges needed
  classes = computed(() => [
    'badge',
    `badge--${this.variant()}`,
    this.rounded() ? 'badge--rounded' : ''
  ].filter(Boolean).join(' '));

  // Transform input values (replaces ngOnChanges transformation)
  count = input(0, { transform: (v: number) => Math.max(0, v) });
  label2 = input('', { transform: (v: string) => v.trim().toUpperCase() });
}

// Parent usage — same template syntax as @Input()
// <app-badge label="Active" [size]="16" variant="success" />
Enter fullscreen mode Exit fullscreen mode

model() — Two-Way Bindable Signal

import { Component, model, computed } from '@angular/core';

// Toggle component with two-way binding
@Component({
  selector: 'app-toggle',
  template: `
    <button
      (click)="toggle()"
      [class.active]="checked()"
      [attr.aria-pressed]="checked()">
      {{ checked() ? 'On' : 'Off' }}
    </button>
  `
})
export class ToggleComponent {
  // model() replaces @Input() + @Output() xxxChange pattern
  checked = model(false);

  toggle() {
    this.checked.update(v => !v); // automatically emits checkedChange event
  }
}

// Rating component
@Component({
  selector: 'app-rating',
  template: `
    @for (star of stars; track star) {
      <button (click)="value.set(star)" [class.filled]="star <= value()">
        ★
      </button>
    }
  `
})
export class RatingComponent {
  value = model.required<number>();
  max = input(5);
  stars = computed(() => Array.from({ length: this.max() }, (_, i) => i + 1));
}

// Parent usage — reactive two-way binding
@Component({
  template: `
    <app-toggle [(checked)]="isEnabled" />
    <app-rating [(value)]="userRating" [max]="10" />

    <p>Enabled: {{ isEnabled() }}</p>
    <p>Rating: {{ userRating() }}/10</p>
  `
})
export class ParentComponent {
  isEnabled = signal(false);
  userRating = signal(5);
}
Enter fullscreen mode Exit fullscreen mode

Migration from @Input/@Output

// Before (Angular < 17.1)
@Component({...})
export class OldComponent {
  @Input() value = 0;
  @Output() valueChange = new EventEmitter<number>();

  increment() { this.valueChange.emit(this.value + 1); }
}

// After (Angular 17.1+ with model())
@Component({...})
export class NewComponent {
  value = model(0); // combines @Input + @Output into one reactive signal

  increment() { this.value.update(v => v + 1); }
  // model() automatically emits 'valueChange' output when updated
}
Enter fullscreen mode Exit fullscreen mode

8. What does zoneless Angular mean and how do you enable it?

Difficulty: Expert

Core Concept

Zoneless Angular removes the Zone.js dependency entirely. Change detection is triggered only by:

  • Signal value changes
  • Explicit ChangeDetectorRef.markForCheck() calls
  • async pipe emissions

Benefits: smaller bundle (~10–15KB), faster startup, simpler mental model, better compatibility with non-Angular async patterns.

// app.config.ts — enable experimental zoneless
import {
  ApplicationConfig,
  provideExperimentalZonelessChangeDetection
} from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(),
  ]
};
Enter fullscreen mode Exit fullscreen mode
// angular.json  remove zone.js from polyfills
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []  // remove "zone.js"
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Migrating Components for Zoneless Compatibility

// ❌ This won't work in zoneless — Zone.js won't detect the change
@Component({
  template: `{{ data }}`
})
export class BadComponent {
  data = '';

  loadData() {
    fetch('/api/data')
      .then(r => r.json())
      .then(d => {
        this.data = d.value; // Zone.js would have caught this; zoneless won't
      });
  }
}

// ✅ Signal-based — works perfectly in zoneless
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ data() }}`
})
export class GoodComponent {
  data = signal('');

  async loadData() {
    const response = await fetch('/api/data');
    const d = await response.json();
    this.data.set(d.value); // Signal update triggers targeted re-render
  }
}

// ✅ Also fine — HttpClient works via async pipe
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `{{ data$ | async }}`
})
export class AlsoGoodComponent {
  data$ = inject(HttpClient).get<string>('/api/data');
}
Enter fullscreen mode Exit fullscreen mode

Zoneless Checklist

Before going zoneless, verify:
✅ All state changes go through Signals, async pipe, or markForCheck()
✅ All components use ChangeDetectionStrategy.OnPush
✅ Third-party libraries are wrapped to trigger CD manually
✅ setTimeout/setInterval updates use signals
✅ WebSocket handlers call markForCheck() or update signals
✅ No direct DOM manipulation that Angular needs to track
Enter fullscreen mode Exit fullscreen mode

9. How do Signals interact with OnPush change detection?

Difficulty: Senior

Core Concept

Signals and OnPush are designed to work together. When a template reads a Signal, Angular registers that component as a consumer of that Signal. When the Signal updates, Angular automatically schedules a targeted change detection pass for that component — no markForCheck() needed.

import { Component, ChangeDetectionStrategy, signal, computed, input } from '@angular/core';

@Component({
  selector: 'app-product-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div [class.featured]="isFeatured()">
      <h3>{{ product().name }}</h3>
      <p>{{ price() }}</p>
      <span [class]="stockClass()">{{ stockLabel() }}</span>
    </div>
  `
})
export class ProductCardComponent {
  // Signal input — reactive by nature, compatible with OnPush
  product = input.required<Product>();

  // Local reactive state
  private discount = signal(0);

  // Derived signals — recalculate only when dependencies change
  price = computed(() => {
    const base = this.product().price;
    const disc = this.discount();
    return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
      .format(base * (1 - disc));
  });

  isFeatured = computed(() => this.product().rating >= 4.5);

  stockClass = computed(() =>
    this.product().stock > 10 ? 'in-stock'
    : this.product().stock > 0 ? 'low-stock'
    : 'out-of-stock'
  );

  stockLabel = computed(() =>
    this.product().stock > 10 ? 'In Stock'
    : this.product().stock > 0 ? `Only ${this.product().stock} left`
    : 'Out of Stock'
  );

  applyDiscount(pct: number) {
    this.discount.set(pct / 100);
    // Angular automatically re-renders because 'price' computed depends on discount
    // No markForCheck() needed even with OnPush
  }
}
Enter fullscreen mode Exit fullscreen mode

Comparison: Old vs New Pattern

// OLD — OnPush + Observable + markForCheck (verbose)
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class OldComponent implements OnInit {
  price = 0;

  constructor(private service: PriceService, private cdr: ChangeDetectorRef) {}

  ngOnInit() {
    this.service.price$.subscribe(p => {
      this.price = p;
      this.cdr.markForCheck(); // required every time
    });
  }
}

// NEW — OnPush + Signals (clean, no markForCheck needed)
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class NewComponent {
  price = toSignal(inject(PriceService).price$, { initialValue: 0 });
  // Template reads price() — Angular handles re-render automatically
}
Enter fullscreen mode Exit fullscreen mode

Performance


10. How does trackBy improve *ngFor performance?

Difficulty: Senior

Core Concept

Without trackBy, Angular uses object identity (===) to diff list items. When the array reference changes (common with HTTP responses), Angular destroys and recreates every DOM node — even if the data is identical. trackBy provides a stable key (usually id) so Angular reuses existing DOM nodes.

import { Component, signal, computed } from '@angular/core';

interface Product {
  id: string;
  name: string;
  price: number;
  inStock: boolean;
}

@Component({
  selector: 'app-product-list',
  template: `
    <!-- Angular 17+ @for — track is mandatory, enforcing best practice -->
    @for (product of products(); track product.id) {
      <app-product-card [product]="product" />
    } @empty {
      <p>No products available.</p>
    }

    <!-- With nested lists — each level needs track -->
    @for (category of categories(); track category.id) {
      <h2>{{ category.name }}</h2>
      @for (item of category.items; track item.id) {
        <app-item [item]="item" />
      }
    }

    <!-- Legacy *ngFor — trackBy function required -->
    <app-product-card
      *ngFor="let product of products(); trackBy: trackById"
      [product]="product"
    />
  `
})
export class ProductListComponent {
  products = signal<Product[]>([]);

  // trackBy function — returns the unique identity key
  trackById = (_index: number, item: Product): string => item.id;

  refresh() {
    // Even though ALL data is re-fetched, Angular reuses DOM nodes
    // for items whose id already exists
    this.http.get<Product[]>('/api/products').subscribe(data => {
      this.products.set(data);
      // With track: unchanged items → DOM reused
      //            changed items   → DOM updated in-place
      //            new items       → DOM created
      //            removed items   → DOM destroyed
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Performance Impact Demonstration

// Without trackBy: refresh of 1000-item list
// → 1000 DOM nodes destroyed, 1000 new DOM nodes created
// → All child components re-initialise (OnInit, subscriptions, etc.)
// → Animation states lost

// With track item.id: same refresh
// → 998 items unchanged → 0 DOM operations
// → 1 item changed → 1 targeted DOM update
// → 1 item added → 1 DOM node created
// → Total: 2 DOM operations instead of 2000
Enter fullscreen mode Exit fullscreen mode

11. Explain lazy loading strategies

Difficulty: Senior

loadComponent — Standalone Component Route

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', redirectTo: 'home', pathMatch: 'full' },

  // Eager — always in main bundle
  { path: 'home', component: HomeComponent },

  // loadComponent — standalone component in separate chunk
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./features/dashboard/dashboard.component')
        .then(m => m.DashboardComponent)
  },

  // loadChildren — entire feature route tree
  {
    path: 'admin',
    canActivate: [authGuard],
    loadChildren: () =>
      import('./features/admin/admin.routes')
        .then(m => m.ADMIN_ROUTES)
  },

  // Scoped providers — services only available in this route
  {
    path: 'checkout',
    loadComponent: () =>
      import('./features/checkout/checkout.component')
        .then(m => m.CheckoutComponent),
    providers: [
      CheckoutService,  // singleton only for checkout route
      PaymentService,
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

@defer — Template-Level Lazy Loading

@Component({
  selector: 'app-product-page',
  template: `
    <!-- Above the fold — load immediately -->
    <app-hero [product]="product" />

    <!-- Interactive on interaction -->
    @defer (on interaction) {
      <app-review-editor [productId]="product.id" />
    } @placeholder {
      <button>Write a review</button>
    }

    <!-- Heavy visualisation — load when scrolled into view -->
    @defer (on viewport; prefetch on idle) {
      <app-sales-chart [data]="salesData" />
    } @placeholder (minimum 100ms) {
      <div class="chart-skeleton"></div>
    } @loading (after 200ms; minimum 500ms) {
      <app-chart-skeleton />
    } @error {
      <p>Failed to load chart. <button (click)="retry()">Retry</button></p>
    }

    <!-- Load after 3 seconds to prioritise critical content -->
    @defer (on timer(3000)) {
      <app-recommendations [userId]="userId" />
    }

    <!-- Condition-based — user must be logged in -->
    @defer (when isAuthenticated()) {
      <app-wishlist-widget />
    }
  `
})
Enter fullscreen mode Exit fullscreen mode

Preloading Strategies

import { PreloadAllModules, NoPreloading, Router } from '@angular/router';
import { QuicklinkStrategy } from 'ngx-quicklink'; // preload visible links

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes,
      // Options:
      withPreloading(PreloadAllModules),  // preload all lazy routes on idle
      withPreloading(NoPreloading),       // default — no preloading
      withPreloading(QuicklinkStrategy),  // preload routes linked on current page

      withComponentInputBinding(),         // pass route data as component inputs
      withViewTransitions(),               // page transition animations
    )
  ]
};
Enter fullscreen mode Exit fullscreen mode

12. What is non-destructive hydration in Angular?

Difficulty: Advanced

Core Concept

In traditional SSR, Angular would discard the server-rendered HTML and rebuild the DOM from scratch on the client. Non-destructive hydration (Angular 16+) reuses the server-rendered DOM, attaches event listeners to existing nodes, and only creates new DOM for components that weren't rendered on the server.

// app.config.ts — enable full hydration
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withFetch()), // fetch-based HTTP for SSR + TransferState
    provideClientHydration(
      withEventReplay() // Angular 18+: replay events that happen before hydration
    ),
  ]
};

// app.config.server.ts — server-side config
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';

export const serverConfig: ApplicationConfig = mergeApplicationConfig(appConfig, {
  providers: [provideServerRendering()]
});
Enter fullscreen mode Exit fullscreen mode

Common Hydration Pitfalls

// ❌ DOM manipulation before hydration completes causes mismatch
@Component({ template: `<div #container></div>` })
export class BadComponent implements OnInit {
  @ViewChild('container') container!: ElementRef;

  ngOnInit() {
    // Server renders nothing; client tries to find this element — mismatch
    this.container.nativeElement.innerHTML = '<p>Dynamic content</p>';
  }
}

// ✅ Use Angular bindings — server and client render the same HTML
@Component({ template: `<div><p>{{ dynamicContent }}</p></div>` })
export class GoodComponent {
  dynamicContent = 'Dynamic content'; // same output server and client
}

// ✅ Use isPlatformBrowser for browser-only code
import { isPlatformBrowser, PLATFORM_ID } from '@angular/common';

@Component({...})
export class SafeComponent {
  private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

  ngOnInit() {
    if (this.isBrowser) {
      // Browser-only APIs: localStorage, document, window
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

13. How do you analyse and reduce Angular bundle size?

Difficulty: Expert

Analysis Tools

# Step 1: Generate stats JSON
ng build --stats-json --configuration=production

# Step 2: Visualise with webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/my-app/browser/stats.json

# Step 3: Check with Angular's built-in size analysis
npx ng build --source-map
npx source-map-explorer 'dist/**/*.js'

# Step 4: Check bundle budgets
ng build --configuration=production
# Angular will warn/error if budgets are exceeded
Enter fullscreen mode Exit fullscreen mode

Bundle Size Reduction Techniques

// 1. Use standalone components (no NgModule overhead)
@Component({
  standalone: true,
  imports: [CommonModule, RouterModule], // only import what you need
})

// 2. Tree-shake services — use providedIn: 'root'
@Injectable({ providedIn: 'root' }) // unused services are removed
export class AnalyticsService {}

// 3. Replace heavy libraries
// ❌ import * as _ from 'lodash';        // entire lodash: ~70KB
// ✅ import { debounce } from 'lodash-es'; // just debounce: ~2KB
// ✅ Write it yourself for simple cases

// 4. Lazy load feature-specific dependencies
@defer (on interaction) {
  <app-rich-text-editor /> // loads prosemirror only when needed
}

// 5. Use Intl API instead of date libraries
// ❌ import { format } from 'date-fns';  // 20KB+
// ✅ new Intl.DateTimeFormat('en-US').format(date); // zero cost

// 6. Use Angular CDK instead of full Material for utilities
// ❌ import { MatTableModule } from '@angular/material/table';
// ✅ import { CdkTableModule } from '@angular/cdk/table';
Enter fullscreen mode Exit fullscreen mode

angular.json Budget Configuration

{
  "configurations": {
    "production": {
      "budgets": [
        {
          "type": "initial",
          "maximumWarning": "400kb",
          "maximumError": "600kb"
        },
        {
          "type": "anyLazyChunk",
          "maximumWarning": "100kb",
          "maximumError": "200kb"
        },
        {
          "type": "anyComponentStyle",
          "maximumWarning": "4kb",
          "maximumError": "8kb"
        },
        {
          "type": "total",
          "maximumWarning": "2mb",
          "maximumError": "3mb"
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

14. How does NgOptimizedImage improve performance?

Difficulty: Advanced

// app.config.ts — register an image CDN loader
import { provideImgixLoader, provideCloudflareLoader } from '@angular/common';

export const appConfig: ApplicationConfig = {
  providers: [
    provideImgixLoader('https://myapp.imgix.net'),
    // or: provideCloudflareLoader('https://myapp.pages.dev')
  ]
};
Enter fullscreen mode Exit fullscreen mode
@Component({
  selector: 'app-product-gallery',
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <!-- LCP hero image — fetchpriority="high" automatically set -->
    <img
      ngSrc="hero-product.jpg"
      width="1200"
      height="600"
      priority
      alt="Premium headphones in studio setting"
    />

    <!-- Product thumbnails — lazy loaded, srcset generated automatically -->
    @for (img of productImages; track img.id) {
      <img
        [ngSrc]="img.src"
        [ngSrcset]="'200w, 400w, 800w'"
        sizes="(max-width: 640px) 100vw, 50vw"
        width="400"
        height="400"
        [alt]="img.alt"
      />
    }

    <!-- Fill container — for responsive hero sections -->
    <div style="position: relative; height: 500px;">
      <img ngSrc="banner.jpg" fill priority alt="Product banner" />
    </div>
  `
})
export class ProductGalleryComponent {
  productImages = [...];
}
Enter fullscreen mode Exit fullscreen mode

What NgOptimizedImage Does Automatically

Feature Without With NgOptimizedImage
LCP priority Manual fetchpriority Auto from priority attribute
Layout shift Manual width/height Required, enforced
Lazy loading Manual loading="lazy" Automatic for non-priority
Responsive srcset Manual Auto-generated for CDN loaders
Oversized images No warning Dev warning if 2x+ oversized
Preconnect hint Manual Auto <link rel="preconnect">

15. What triggers can you use with @defer?

Difficulty: Expert

@Component({
  template: `
    <!-- 1. on idle — browser is not busy -->
    @defer (on idle) {
      <app-analytics-tracker />
    }

    <!-- 2. on viewport — element scrolls into view -->
    @defer (on viewport) {
      <app-comments-section />
    } @placeholder {
      <div class="comments-placeholder" style="height: 400px;"></div>
    }

    <!-- 3. on interaction — click or focus on placeholder -->
    @defer (on interaction) {
      <app-video-player [src]="videoUrl" />
    } @placeholder {
      <button class="play-btn">▶ Play Video</button>
    }

    <!-- 4. on hover — mouse enters placeholder -->
    @defer (on hover) {
      <app-tooltip-content />
    } @placeholder {
      <span class="info-icon">ℹ</span>
    }

    <!-- 5. on immediate — loads immediately (splits to lazy chunk still) -->
    @defer (on immediate) {
      <app-below-fold-hero />
    }

    <!-- 6. on timer — after N milliseconds -->
    @defer (on timer(2000)) {
      <app-chat-widget />
    }

    <!-- 7. when — custom boolean condition -->
    @defer (when userHasScrolled() && isAuthenticated()) {
      <app-premium-content />
    }

    <!-- 8. Combine triggers (OR logic) -->
    @defer (on viewport; on timer(5000)) {
      <app-newsletter-signup />
    }

    <!-- 9. Prefetch separately from render trigger -->
    @defer (on interaction; prefetch on idle) {
      <app-rich-editor />
    } @placeholder {
      <div class="editor-placeholder">Click to edit...</div>
    } @loading (after 100ms; minimum 400ms) {
      <app-editor-skeleton />
    } @error {
      <p class="error">
        Failed to load editor.
        <button (click)="retry()">Retry</button>
      </p>
    }
  `
})
export class ContentPageComponent {
  userHasScrolled = signal(false);
  isAuthenticated = inject(AuthService).isLoggedIn;

  @HostListener('window:scroll')
  onScroll() { this.userHasScrolled.set(true); }
}
Enter fullscreen mode Exit fullscreen mode

RxJS


16. Compare switchMap, mergeMap, concatMap, and exhaustMap

Difficulty: Senior

Visual Comparison

Input:  A----B--------C-------D-->

switchMap:   --a---a| --b---b| --c---c| --d---d-->
             (cancels A when B arrives)

mergeMap:    --a---a---a---a-->
             --b---b---b-->          (all run concurrently)

concatMap:   --a---a---a---b---b---b---c---c---c-->
             (queued, each waits for previous)

exhaustMap:  --a---a---a|----c---c---c-->
             (B and D ignored while A and C are active)
Enter fullscreen mode Exit fullscreen mode

Practical Use Cases with Code

import { fromEvent, interval } from 'rxjs';
import { switchMap, mergeMap, concatMap, exhaustMap, debounceTime } from 'rxjs/operators';

// --- switchMap: SEARCH / AUTOCOMPLETE ---
// Cancels the previous HTTP call when user types again
searchControl.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(query =>
    this.http.get<SearchResult[]>(`/api/search?q=${query}`).pipe(
      catchError(() => of([])) // if cancelled, catchError handles it
    )
  )
).subscribe(results => this.results.set(results));

// --- mergeMap: PARALLEL INDEPENDENT OPERATIONS ---
// Upload multiple files simultaneously, all progress tracked
selectedFiles$.pipe(
  mergeMap(file =>
    this.uploadService.upload(file).pipe(
      tap(progress => this.updateProgress(file.name, progress))
    )
  )
).subscribe();

// --- concatMap: SEQUENTIAL ORDERED OPERATIONS ---
// Process commands in order: save step 1, then step 2, then step 3
saveCommands$.pipe(
  concatMap(command => this.api.save(command)) // never overlaps
).subscribe(result => this.onSaved(result));

// Optimistic update queue
userActions$.pipe(
  concatMap(action =>
    this.http.post('/api/actions', action).pipe(
      retry(2),
      catchError(err => {
        this.rollback(action);
        return EMPTY;
      })
    )
  )
).subscribe();

// --- exhaustMap: PREVENT DUPLICATE SUBMIT / LOGIN ---
// Ignores new clicks while login is in progress
fromEvent(loginBtn, 'click').pipe(
  exhaustMap(() =>
    this.authService.login(credentials).pipe(
      catchError(err => {
        this.error.set(err.message);
        return EMPTY;
      })
    )
  )
).subscribe(user => this.router.navigate(['/dashboard']));
Enter fullscreen mode Exit fullscreen mode

17. What are the best patterns to avoid RxJS memory leaks?

Difficulty: Senior

// ✅ PATTERN 1: takeUntilDestroyed (Angular 16+ — RECOMMENDED)
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';

@Component({...})
export class ModernComponent {
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    // Automatically unsubscribes when component is destroyed
    interval(1000).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(tick => this.tick.set(tick));

    // Works outside injection context too
    this.dataService.liveData$.pipe(
      takeUntilDestroyed(this.destroyRef),
      distinctUntilChanged()
    ).subscribe(data => this.data.set(data));
  }
}

// ✅ PATTERN 2: async pipe (best for template bindings)
@Component({
  template: `
    @if (user$ | async; as user) {
      <app-profile [user]="user" />
    }
    @for (item of items$ | async ?? []; track item.id) {
      <app-item [item]="item" />
    }
  `
})
export class AsyncPipeComponent {
  // No subscribe(), no unsubscribe() — async pipe handles everything
  user$ = this.authService.currentUser$;
  items$ = this.itemService.getItems();
}

// ✅ PATTERN 3: Subscription collection
@Component({...})
export class OldStyleComponent implements OnDestroy {
  private subs = new Subscription();

  ngOnInit() {
    // Add all subscriptions — one unsubscribe call cleans all
    this.subs.add(this.service1.data$.subscribe(d => this.data1 = d));
    this.subs.add(this.service2.events$.subscribe(e => this.handle(e)));
    this.subs.add(interval(5000).subscribe(() => this.refresh()));
  }

  ngOnDestroy() {
    this.subs.unsubscribe(); // cleans all at once
  }
}

// ❌ COMMON LEAK: subscribing in service without cleanup
@Injectable({ providedIn: 'root' })
export class BadService {
  constructor() {
    // This subscription lives forever — the service is a singleton!
    this.http.get('/api').subscribe(); // LEAK
  }
}

// ✅ FIX: use first() or take(1) for one-shot requests
@Injectable({ providedIn: 'root' })
export class GoodService {
  getData() {
    return this.http.get('/api').pipe(take(1)); // completes after 1 emission
  }
}
Enter fullscreen mode Exit fullscreen mode

18. How do you handle errors in RxJS without killing a stream?

Difficulty: Advanced

import { catchError, retry, retryWhen, delayWhen, timer, EMPTY, of } from 'rxjs';

// ❌ WRONG — catchError re-throws → stream terminates
source$.pipe(
  catchError(err => { throw err; }) // stream is dead
);

// ✅ PATTERN 1: Recover with fallback value
this.http.get<User[]>('/api/users').pipe(
  catchError(err => {
    console.error('Failed to load users:', err);
    this.errorToast.show('Could not load users');
    return of([]); // stream continues with empty array
  })
);

// ✅ PATTERN 2: Isolate inner stream — keep outer alive
// Common for WebSocket message handlers, long-polling
this.ws.messages$.pipe(
  switchMap(id =>
    this.http.get(`/api/data/${id}`).pipe(
      catchError(err => {
        this.errors.update(e => [...e, { id, err }]);
        return EMPTY; // inner stream ends gracefully, outer ws.messages$ continues
      })
    )
  )
).subscribe(data => this.process(data));

// ✅ PATTERN 3: Retry with exponential backoff
function exponentialBackoff(maxRetries: number) {
  return retryWhen(errors =>
    errors.pipe(
      scan((retryCount, err) => {
        if (retryCount >= maxRetries) throw err; // give up after max retries
        return retryCount + 1;
      }, 0),
      delayWhen(retryCount => timer(Math.pow(2, retryCount) * 1000))
      // Delays: 2s, 4s, 8s, 16s...
    )
  );
}

this.http.get('/api/critical-data').pipe(
  exponentialBackoff(4),
  catchError(err => {
    this.criticalError.set(true);
    return EMPTY;
  })
);

// ✅ PATTERN 4: Angular 16+ retry() with config
this.http.get('/api/data').pipe(
  retry({
    count: 3,
    delay: (error, retryIndex) => timer(retryIndex * 1000),
    resetOnSuccess: true
  })
);
Enter fullscreen mode Exit fullscreen mode

19. How do you write a custom RxJS operator?

Difficulty: Expert

import { Observable, pipe, timer, OperatorFunction } from 'rxjs';
import { switchMap, takeWhile, retry, tap, filter } from 'rxjs/operators';

// PATTERN 1: Compose existing operators
function debounceDistinct<T>(dueTime: number): OperatorFunction<T, T> {
  return pipe(
    debounceTime(dueTime),
    distinctUntilChanged()
  );
}

// Usage
searchTerm$.pipe(debounceDistinct(300));

// PATTERN 2: Full custom operator with Observable constructor
function pollUntil<T>(
  intervalMs: number,
  predicate: (value: T) => boolean,
  maxPolls = 20
): OperatorFunction<T, T> {
  return (source$: Observable<T>) =>
    timer(0, intervalMs).pipe(
      take(maxPolls),
      switchMap(() => source$),
      takeWhile(value => !predicate(value), true) // true = emit the final value
    );
}

// PATTERN 3: Operator with side effects and logging
function tapOnFirst<T>(fn: (value: T) => void): OperatorFunction<T, T> {
  return (source$: Observable<T>) =>
    new Observable(subscriber => {
      let isFirst = true;
      return source$.subscribe({
        next(value) {
          if (isFirst) {
            fn(value);
            isFirst = false;
          }
          subscriber.next(value);
        },
        error: (e) => subscriber.error(e),
        complete: () => subscriber.complete()
      });
    });
}

// PATTERN 4: Real-world — job status polling
function pollJobStatus(jobId: string, jobService: JobService) {
  return jobService.getStatus(jobId).pipe(
    pollUntil(2000, status => ['complete', 'failed'].includes(status.state)),
    tapOnFirst(() => console.log('Job started polling')),
    catchError(err => {
      console.error('Polling failed:', err);
      return of({ state: 'error', jobId });
    })
  );
}

// Usage in component
this.submitJob(payload).pipe(
  switchMap(job => pollJobStatus(job.id, this.jobService))
).subscribe(status => {
  if (status.state === 'complete') this.onComplete(status);
  if (status.state === 'failed') this.onFailed(status);
});
Enter fullscreen mode Exit fullscreen mode

20. Compare Subject, BehaviorSubject, ReplaySubject, and AsyncSubject

Difficulty: Senior

import { Subject, BehaviorSubject, ReplaySubject, AsyncSubject } from 'rxjs';

// --- Subject — plain multicast event bus ---
const clicks$ = new Subject<MouseEvent>();
// Late subscribers get nothing from before they subscribed
clicks$.subscribe(e => console.log('A:', e.type));
clicks$.next(new MouseEvent('click'));
clicks$.subscribe(e => console.log('B:', e.type)); // B misses the first click
clicks$.next(new MouseEvent('click')); // both A and B get this

// --- BehaviorSubject — always has a current value ---
const theme$ = new BehaviorSubject<'light' | 'dark'>('light');
theme$.subscribe(t => console.log('Current:', t)); // immediately logs 'light'
theme$.next('dark');
// Late subscriber immediately gets 'dark'
theme$.subscribe(t => console.log('Late sub:', t)); // logs 'dark' immediately

// Synchronous read — useful in guards, resolvers
const currentTheme = theme$.value; // 'dark'

// Real-world: auth state store
@Injectable({ providedIn: 'root' })
export class AuthStore {
  private _user = new BehaviorSubject<User | null>(null);

  // Expose as read-only Observable — hide Subject from consumers
  user$ = this._user.asObservable();

  // Synchronous access
  get currentUser(): User | null { return this._user.value; }

  setUser(user: User) { this._user.next(user); }
  logout() { this._user.next(null); }
}

// --- ReplaySubject — caches last N emissions ---
const log$ = new ReplaySubject<string>(5); // buffer last 5 entries
log$.next('App started');
log$.next('User logged in');
log$.next('Page navigated');

// Late subscriber immediately receives last 5 entries
log$.subscribe(entry => console.log(entry)); // gets all 3 above

// Real-world: error log buffer, recent notifications
const recentErrors$ = new ReplaySubject<AppError>(10);

// --- AsyncSubject — only emits last value on complete ---
const singleResult$ = new AsyncSubject<number>();
singleResult$.next(1);
singleResult$.next(2);
singleResult$.next(3);
singleResult$.subscribe(v => console.log('Result:', v)); // nothing yet
singleResult$.complete(); // NOW emits: Result: 3

// Real-world: one-time computed result, WebWorker response
Enter fullscreen mode Exit fullscreen mode

21. What are the advantages of async pipe over manual subscriptions?

Difficulty: Senior

// ❌ Manual subscription — 6 things to get right
@Component({...})
export class ManualComponent implements OnInit, OnDestroy {
  users: User[] = [];
  isLoading = true;
  error: string | null = null;
  private sub = new Subscription();

  ngOnInit() {
    this.sub.add(
      this.userService.getUsers().subscribe({
        next: users => {
          this.users = users;
          this.isLoading = false;
          this.cdr.markForCheck(); // required with OnPush
        },
        error: err => {
          this.error = err.message;
          this.isLoading = false;
          this.cdr.markForCheck(); // easy to forget
        }
      })
    );
  }

  ngOnDestroy() { this.sub.unsubscribe(); } // easy to forget
}

// ✅ Async pipe — handles all of it
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (users$ | async; as users) {
      @for (user of users; track user.id) {
        <app-user-card [user]="user" />
      }
    } @else {
      <app-skeleton [count]="5" />
    }
  `
})
export class AsyncPipeComponent {
  // Auto-subscribes, auto-unsubscribes, triggers CD on emission
  users$ = this.userService.getUsers();

  constructor(private userService: UserService) {}
  // No ngOnInit, no ngOnDestroy, no markForCheck()
}

// ✅ Async pipe with error handling
@Component({
  template: `
    @if (data$ | async; as data) {
      <app-display [data]="data" />
    }
    @if (error$ | async; as error) {
      <app-error [message]="error" />
    }
  `
})
export class SafeComponent {
  private raw$ = this.service.getData();

  data$ = this.raw$.pipe(catchError(() => EMPTY));
  error$ = this.raw$.pipe(
    ignoreElements(),
    catchError(err => of(err.message))
  );
}
Enter fullscreen mode Exit fullscreen mode

Architecture


22. Explain hierarchical dependency injection

Difficulty: Senior

// LEVEL 1: Root Injector — application singleton
@Injectable({ providedIn: 'root' })
export class AuthService {
  // One instance for the entire app
}

// LEVEL 2: Environment Injector — route-level (Angular 14+)
// Routes can have their own providers array
export const routes: Routes = [{
  path: 'admin',
  component: AdminComponent,
  providers: [
    AdminService,            // scoped to /admin route and its children
    { provide: REPORT_CONFIG, useValue: { format: 'xlsx' } }
  ]
}];

// LEVEL 3: Element Injector — component-level override
@Component({
  selector: 'app-user-list',
  providers: [
    UserService,                          // new instance just for this subtree
    { provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor, multi: true }
  ]
})
export class UserListComponent {
  // Gets a fresh UserService, not the root singleton
  constructor(private userService: UserService) {}
}

// ADVANCED: Injection modifiers
@Component({...})
export class ChildComponent {
  // @Optional: don't throw if token not found — return null
  @Optional() private analytics = inject(AnalyticsService, { optional: true });

  // @SkipSelf: start lookup from parent injector, skip own
  @SkipSelf() private parentConfig = inject(CONFIG_TOKEN);

  // @Host: only look in the host component's providers, not ancestors
  @Host() private formGroup = inject(ControlContainer);

  // @Self: only look in this component's own injector
  @Self() private localService = inject(LocalService);
}

// forwardRef: circular references
@Injectable()
export class ServiceA {
  constructor(@Inject(forwardRef(() => ServiceB)) private b: ServiceB) {}
}
Enter fullscreen mode Exit fullscreen mode

23. What is the recommended Angular 17+ application architecture?

Difficulty: Senior

// main.ts — no AppModule, no platformBrowserDynamic
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig).catch(console.error);

// app.config.ts — centralised provider configuration
import { ApplicationConfig } from '@angular/core';
import {
  provideRouter, withComponentInputBinding, withViewTransitions, withPreloading, PreloadAllModules
} from '@angular/router';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes,
      withComponentInputBinding(),    // route params as @Input()
      withViewTransitions(),           // page transitions
      withPreloading(PreloadAllModules)
    ),
    provideHttpClient(
      withFetch(),
      withInterceptors([authInterceptor, loggingInterceptor])
    ),
    provideAnimations(),
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
  ]
};

// app.component.ts — shell, pure router outlet
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, NavComponent, FooterComponent],
  template: `
    <app-nav />
    <main>
      <router-outlet />
    </main>
    <app-footer />
  `
})
export class AppComponent {}

// Feature route with scoped providers
// features/admin/admin.routes.ts
export const ADMIN_ROUTES: Routes = [
  {
    path: '',
    providers: [AdminService, AdminGuard],
    children: [
      { path: '', component: AdminDashboardComponent },
      { path: 'users', loadComponent: () => import('./users/users.component').then(m => m.UsersComponent) },
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

Recommended Folder Structure

src/
├── app/
│   ├── app.component.ts           ← Shell
│   ├── app.config.ts              ← Root providers
│   ├── app.routes.ts              ← Top-level routes
│   ├── core/
│   │   ├── guards/                ← Functional guards
│   │   ├── interceptors/          ← Functional interceptors
│   │   ├── services/              ← App-wide singleton services
│   │   └── models/                ← Shared interfaces/types
│   ├── features/
│   │   ├── dashboard/
│   │   │   ├── dashboard.component.ts
│   │   │   ├── dashboard.routes.ts
│   │   │   └── dashboard.service.ts
│   │   └── admin/
│   │       ├── admin.component.ts
│   │       ├── admin.routes.ts
│   │       └── services/
│   └── shared/
│       ├── components/            ← Dumb/presentational components
│       ├── directives/
│       └── pipes/
├── environments/
└── assets/
Enter fullscreen mode Exit fullscreen mode

24. When would you choose Signals over NgRx?

Difficulty: Advanced

// ✅ USE SIGNALS FOR: local + shared service state (most apps)
@Injectable({ providedIn: 'root' })
export class CartStore {
  // Private writable signals
  private _items = signal<CartItem[]>([]);
  private _coupon = signal<string | null>(null);

  // Public read-only computed state
  readonly items = this._items.asReadonly();
  readonly coupon = this._coupon.asReadonly();
  readonly count = computed(() => this._items().length);
  readonly subtotal = computed(() =>
    this._items().reduce((s, i) => s + i.price * i.qty, 0)
  );
  readonly discount = computed(() => {
    const c = this._coupon();
    return c ? this.couponService.getDiscount(c) : 0;
  });
  readonly total = computed(() => this.subtotal() - this.discount());
  readonly isEmpty = computed(() => this._items().length === 0);

  addItem(item: CartItem) {
    this._items.update(items => {
      const existing = items.find(i => i.id === item.id);
      return existing
        ? items.map(i => i.id === item.id ? { ...i, qty: i.qty + 1 } : i)
        : [...items, { ...item, qty: 1 }];
    });
  }

  removeItem(id: string) {
    this._items.update(items => items.filter(i => i.id !== id));
  }

  applyCoupon(code: string) { this._coupon.set(code); }
  clearCart() { this._items.set([]); this._coupon.set(null); }
}

// ✅ USE NgRx FOR: complex async side effects, dev tooling, large teams
// @ngrx/store + @ngrx/effects pattern (abbreviated)
export const CartActions = createActionGroup({
  source: 'Cart',
  events: {
    'Add Item': props<{ item: CartItem }>(),
    'Checkout Started': emptyProps(),
    'Checkout Success': props<{ orderId: string }>(),
    'Checkout Failed': props<{ error: string }>(),
  }
});

// NgRx shines with: time-travel debugging, complex Effect pipelines,
// normalised entity state (EntityAdapter), large teams needing conventions
Enter fullscreen mode Exit fullscreen mode

Decision Framework

Choose Signals when:
✅ State is local to a feature or small set of components
✅ Derivations are synchronous
✅ Team is small-medium (1-10 devs)
✅ You want less boilerplate
✅ Starting a new project

Choose NgRx when:
✅ Complex async side-effect orchestration (payment, auth flows)
✅ Need Redux DevTools for time-travel debugging
✅ Large team needing strict conventions
✅ Normalised entity cache (NgRx Entity)
✅ Already have significant NgRx investment
Enter fullscreen mode Exit fullscreen mode

25. How do functional HTTP interceptors work?

Difficulty: Advanced

import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpEvent } from '@angular/common/http';
import { inject } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';

// --- Auth Interceptor ---
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authStore = inject(AuthStore);
  const token = authStore.accessToken();

  // Don't intercept auth endpoints
  if (req.url.includes('/auth/')) return next(req);

  const authReq = token
    ? req.clone({ headers: req.headers.set('Authorization', `Bearer ${token}`) })
    : req;

  return next(authReq).pipe(
    catchError(err => {
      if (err.status === 401) {
        // Attempt token refresh
        return authStore.refreshToken().pipe(
          switchMap(newToken =>
            next(req.clone({ headers: req.headers.set('Authorization', `Bearer ${newToken}`) }))
          ),
          catchError(refreshErr => {
            authStore.logout();
            inject(Router).navigate(['/login']);
            return throwError(() => refreshErr);
          })
        );
      }
      return throwError(() => err);
    })
  );
};

// --- Logging Interceptor ---
export const loggingInterceptor: HttpInterceptorFn = (req, next) => {
  const start = performance.now();
  const logger = inject(LoggerService);

  logger.debug(`[HTTP] ${req.method} ${req.url}`);

  return next(req).pipe(
    tap({
      next: event => {
        if (event instanceof HttpResponse) {
          const elapsed = Math.round(performance.now() - start);
          logger.debug(`[HTTP] ${req.method} ${req.url}${event.status} (${elapsed}ms)`);
        }
      },
      error: err => logger.error(`[HTTP] ${req.method} ${req.url} → ERROR`, err)
    })
  );
};

// --- Retry Interceptor ---
export const retryInterceptor: HttpInterceptorFn = (req, next) => {
  return next(req).pipe(
    retry({
      count: 3,
      delay: (error, index) => {
        if (error.status >= 400 && error.status < 500) {
          // Don't retry client errors
          return throwError(() => error);
        }
        return timer(index * 1000); // 1s, 2s, 3s backoff
      }
    })
  );
};

// app.config.ts — interceptors run in order
provideHttpClient(
  withInterceptors([loggingInterceptor, authInterceptor, retryInterceptor])
)
Enter fullscreen mode Exit fullscreen mode

26. How do you implement micro frontends with Module Federation?

Difficulty: Expert

// webpack.config.js — Remote app (e.g., product-catalog app)
const ModuleFederationPlugin = require('@module-federation/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'productCatalog',
      filename: 'remoteEntry.js',
      exposes: {
        // Expose Angular route config
        './Routes': './src/app/catalog/catalog.routes.ts',
        // Or expose a standalone component directly
        './ProductCard': './src/app/shared/product-card.component.ts',
      },
      shared: {
        '@angular/core': { singleton: true, strictVersion: true, requiredVersion: '^17.0.0' },
        '@angular/common': { singleton: true, strictVersion: true },
        '@angular/router': { singleton: true, strictVersion: true },
        'rxjs': { singleton: true },
      }
    })
  ]
};

// webpack.config.js — Host app (shell)
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        productCatalog: 'productCatalog@http://localhost:4201/remoteEntry.js',
        checkout: 'checkout@http://localhost:4202/remoteEntry.js',
      },
      shared: { /* same as remote */ }
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode
// shell/app.routes.ts — dynamically load remote routes
import { loadRemoteModule } from '@angular-architects/module-federation';

export const routes: Routes = [
  {
    path: 'catalog',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: 'http://localhost:4201/remoteEntry.js',
        exposedModule: './Routes',
      }).then(m => m.CATALOG_ROUTES),
  },
  {
    path: 'checkout',
    loadChildren: () =>
      loadRemoteModule({
        type: 'module',
        remoteEntry: 'http://localhost:4202/remoteEntry.js',
        exposedModule: './Routes',
      }).then(m => m.CHECKOUT_ROUTES),
  }
];

// Dynamic remotes from API (runtime configuration)
export async function loadDynamicRemotes() {
  const manifest = await fetch('/assets/mf.manifest.json').then(r => r.json());
  // manifest = { productCatalog: 'http://cdn.example.com/remoteEntry.js', ... }

  await Promise.all(
    Object.entries(manifest).map(([name, url]) =>
      loadRemoteModule({ type: 'module', remoteEntry: url as string, exposedModule: './Routes' })
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

27. How do functional route guards work?

Difficulty: Senior

import { CanActivateFn, CanMatchFn, ResolveFn, Router } from '@angular/router';
import { inject } from '@angular/core';

// --- Basic auth guard ---
export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isAuthenticated()) return true;

  // Preserve attempted URL for redirect after login
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url }
  });
};

// --- Role guard factory — reusable with different roles ---
export const roleGuard = (...roles: UserRole[]): CanActivateFn => {
  return (route, state) => {
    const auth = inject(AuthService);
    const router = inject(Router);

    if (roles.some(role => auth.hasRole(role))) return true;

    return router.createUrlTree(['/forbidden']);
  };
};

// --- canMatch — prevents route from matching (doesn't redirect) ---
export const featureFlag: CanMatchFn = (route) => {
  const features = inject(FeatureFlagService);
  const flagName = route.data?.['featureFlag'] as string;
  return features.isEnabled(flagName); // if false, router tries next matching route
};

// --- Async guard — checks server ---
export const subscriptionGuard: CanActivateFn = () => {
  const billing = inject(BillingService);
  const router = inject(Router);

  return billing.hasActiveSubscription().pipe(
    take(1),
    map(hasSubscription => hasSubscription || router.createUrlTree(['/upgrade']))
  );
};

// app.routes.ts — apply guards
export const routes: Routes = [
  {
    path: 'admin',
    canActivate: [authGuard, roleGuard('ADMIN', 'SUPER_ADMIN')],
    canMatch: [featureFlag],
    data: { featureFlag: 'adminPanel' },
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
  {
    path: 'dashboard',
    canActivate: [authGuard, subscriptionGuard],
    loadComponent: () => import('./dashboard/dashboard.component'),
  }
];
Enter fullscreen mode Exit fullscreen mode

28. How do you create a pure vs impure pipe?

Difficulty: Senior

import { Pipe, PipeTransform } from '@angular/core';

// --- Pure pipe (default) — memoized, only runs when input reference changes ---
@Pipe({ name: 'currency', standalone: true })
export class CurrencyFormatPipe implements PipeTransform {
  private formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });

  transform(value: number | null, currency = 'USD'): string {
    if (value === null || value === undefined) return '';
    return this.formatter.format(value);
  }
}

// --- Impure pipe — runs on EVERY change detection cycle ---
// Only use when you MUST react to mutable data changes
@Pipe({ name: 'search', standalone: true, pure: false })
export class SearchPipe implements PipeTransform {
  transform(items: Item[], term: string): Item[] {
    if (!term) return items;
    const lower = term.toLowerCase();
    return items.filter(i => i.name.toLowerCase().includes(lower));
  }
}
// ⚠️ Impure pipes run on EVERY change detection cycle — avoid in large lists
// Better: filter in the component and pass filtered array via @Input

// --- Real-world pure pipe examples ---
@Pipe({ name: 'relativeTime', standalone: true })
export class RelativeTimePipe implements PipeTransform {
  private rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });

  transform(date: Date | string | number): string {
    const d = new Date(date);
    const now = Date.now();
    const diffMs = d.getTime() - now;
    const diffSec = diffMs / 1000;
    const diffMin = diffSec / 60;
    const diffHour = diffMin / 60;
    const diffDay = diffHour / 24;

    if (Math.abs(diffSec) < 60) return this.rtf.format(Math.round(diffSec), 'second');
    if (Math.abs(diffMin) < 60) return this.rtf.format(Math.round(diffMin), 'minute');
    if (Math.abs(diffHour) < 24) return this.rtf.format(Math.round(diffHour), 'hour');
    return this.rtf.format(Math.round(diffDay), 'day');
  }
}

// Usage: {{ post.createdAt | relativeTime }} → "2 hours ago"
Enter fullscreen mode Exit fullscreen mode

29. How do you use InjectionToken?

Difficulty: Advanced

import { InjectionToken, inject } from '@angular/core';

// --- Type-safe configuration ---
export interface AppConfig {
  apiBaseUrl: string;
  maxRetries: number;
  featureFlags: Record<string, boolean>;
  environment: 'development' | 'staging' | 'production';
}

export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG', {
  providedIn: 'root',
  factory: () => ({
    apiBaseUrl: '/api',
    maxRetries: 3,
    featureFlags: {},
    environment: 'development'
  })
});

// --- Abstract service token — swap implementations ---
export interface StorageService {
  get<T>(key: string): T | null;
  set<T>(key: string, value: T): void;
  remove(key: string): void;
}

export const STORAGE_SERVICE = new InjectionToken<StorageService>('STORAGE_SERVICE');

// Implementations
@Injectable()
export class LocalStorageService implements StorageService {
  get<T>(key: string): T | null {
    const item = localStorage.getItem(key);
    return item ? JSON.parse(item) : null;
  }
  set<T>(key: string, value: T): void {
    localStorage.setItem(key, JSON.stringify(value));
  }
  remove(key: string): void { localStorage.removeItem(key); }
}

@Injectable()
export class InMemoryStorageService implements StorageService {
  private store = new Map<string, any>();
  get<T>(key: string): T | null { return this.store.get(key) ?? null; }
  set<T>(key: string, value: T): void { this.store.set(key, value); }
  remove(key: string): void { this.store.delete(key); }
}

// app.config.ts — provide real implementation
providers: [{ provide: STORAGE_SERVICE, useClass: LocalStorageService }]

// test setup — provide mock
providers: [{ provide: STORAGE_SERVICE, useClass: InMemoryStorageService }]

// Consume in any service/component
@Injectable({ providedIn: 'root' })
export class UserPreferencesService {
  private storage = inject(STORAGE_SERVICE);
  private config = inject(APP_CONFIG);

  savePreference(key: string, value: unknown) {
    this.storage.set(`${this.config.environment}:${key}`, value);
  }
}
Enter fullscreen mode Exit fullscreen mode

30. Explain multi-slot content projection

Difficulty: Advanced

import { Component, ContentChild, TemplateRef } from '@angular/core';

// --- Multi-slot projection with CSS selectors ---
@Component({
  selector: 'app-data-card',
  standalone: true,
  template: `
    <div class="card" [class.collapsible]="collapsible">
      <!-- Named slot: any element with [card-icon] attribute -->
      <div class="card-icon">
        <ng-content select="[card-icon]"></ng-content>
      </div>

      <!-- Named slot: elements with card-title attribute -->
      <div class="card-header">
        <ng-content select="[card-title]"></ng-content>
        <ng-content select="app-badge"></ng-content> <!-- by component selector -->
      </div>

      <!-- Default slot: everything else -->
      <div class="card-body">
        <ng-content></ng-content>
      </div>

      <!-- Named slot: footer actions -->
      <div class="card-footer" *ngIf="hasFooter">
        <ng-content select="[card-actions]"></ng-content>
      </div>
    </div>
  `
})
export class DataCardComponent {
  @ContentChild('[card-actions]') footerContent!: ElementRef;
  get hasFooter() { return !!this.footerContent; }
}

// Usage
@Component({
  template: `
    <app-data-card>
      <!-- Goes into [card-icon] slot -->
      <i card-icon class="ti ti-user"></i>

      <!-- Goes into [card-title] slot -->
      <h3 card-title>User Profile</h3>

      <!-- Goes into default slot -->
      <p>Main content of the card.</p>
      <p>More content here.</p>

      <!-- Goes into [card-actions] slot -->
      <div card-actions>
        <button (click)="edit()">Edit</button>
        <button (click)="delete()">Delete</button>
      </div>
    </app-data-card>
  `
})

// --- ngTemplateOutlet — maximum flexibility ---
@Component({
  selector: 'app-list',
  template: `
    @for (item of items; track item.id) {
      <ng-container
        [ngTemplateOutlet]="itemTemplate"
        [ngTemplateOutletContext]="{ $implicit: item, index: $index, isLast: $last }">
      </ng-container>
    }

    <!-- Fallback template if none provided -->
    <ng-template #defaultItem let-item>
      <div>{{ item.name }}</div>
    </ng-template>
  `
})
export class ListComponent<T extends { id: string; name: string }> {
  @Input() items: T[] = [];
  @ContentChild(TemplateRef) itemTemplate!: TemplateRef<any>;
}

// Usage with custom template
<app-list [items]="products">
  <ng-template let-product let-i="index">
    <div class="product-row">
      <span>{{ i + 1 }}.</span>
      <img [src]="product.image" />
      <h4>{{ product.name }}</h4>
    </div>
  </ng-template>
</app-list>
Enter fullscreen mode Exit fullscreen mode

31. How do you dynamically create components at runtime?

Difficulty: Expert

import {
  Component, Injectable, Type, ViewContainerRef, ComponentRef,
  ApplicationRef, EnvironmentInjector, createComponent
} from '@angular/core';

// --- Pattern 1: ViewContainerRef (for anchored placement) ---
@Injectable({ providedIn: 'root' })
export class DialogService {
  private viewContainerRef!: ViewContainerRef;
  private componentRef!: ComponentRef<any>;

  setViewContainerRef(vcr: ViewContainerRef) {
    this.viewContainerRef = vcr;
  }

  open<T>(component: Type<T>, inputs?: Partial<T>): ComponentRef<T> {
    this.viewContainerRef.clear();
    const ref = this.viewContainerRef.createComponent(component);

    if (inputs) {
      Object.entries(inputs).forEach(([key, value]) => {
        ref.setInput(key, value); // type-safe input setting
      });
    }

    // Listen for close event
    (ref.instance as any).closed?.subscribe(() => this.close());

    ref.changeDetectorRef.detectChanges();
    return ref;
  }

  close() {
    this.viewContainerRef.clear();
  }
}

// --- Pattern 2: ApplicationRef (app-level, no anchor needed — for toasts) ---
@Injectable({ providedIn: 'root' })
export class ToastService {
  private toasts: ComponentRef<ToastComponent>[] = [];

  constructor(
    private appRef: ApplicationRef,
    private injector: EnvironmentInjector
  ) {}

  show(message: string, type: 'success' | 'error' | 'info' = 'info') {
    const ref = createComponent(ToastComponent, {
      environmentInjector: this.injector,
    });

    ref.setInput('message', message);
    ref.setInput('type', type);

    // Attach to Angular's change detection
    this.appRef.attachView(ref.hostView);

    // Append to DOM
    document.body.appendChild(ref.location.nativeElement);

    this.toasts.push(ref);

    // Auto-dismiss after 4 seconds
    setTimeout(() => this.dismiss(ref), 4000);

    return ref;
  }

  dismiss(ref: ComponentRef<ToastComponent>) {
    this.appRef.detachView(ref.hostView);
    ref.location.nativeElement.remove();
    ref.destroy();
    this.toasts = this.toasts.filter(t => t !== ref);
  }
}

// Toast component
@Component({
  selector: 'app-toast',
  standalone: true,
  template: `
    <div [class]="'toast toast--' + type()">
      {{ message() }}
      <button (click)="toastService.dismiss(this)">×</button>
    </div>
  `
})
export class ToastComponent {
  message = input('');
  type = input<'success' | 'error' | 'info'>('info');
}
Enter fullscreen mode Exit fullscreen mode

32. How does Angular Router's resolve data strategy work?

Difficulty: Senior

import { ResolveFn, ActivatedRouteSnapshot } from '@angular/router';
import { inject } from '@angular/core';

// --- Functional resolver (Angular 14+) ---
export const productResolver: ResolveFn<Product> = (route) => {
  const productService = inject(ProductService);
  const router = inject(Router);
  const id = route.paramMap.get('id')!;

  return productService.getProduct(id).pipe(
    take(1),
    catchError(err => {
      if (err.status === 404) {
        router.navigate(['/not-found']);
      } else {
        router.navigate(['/error'], { queryParams: { code: err.status } });
      }
      return EMPTY; // prevents navigation from completing
    })
  );
};

// --- Parallel resolvers (run concurrently) ---
export const productDetailResolver: ResolveFn<ProductDetailPageData> = async (route) => {
  const productService = inject(ProductService);
  const reviewService = inject(ReviewService);
  const id = route.paramMap.get('id')!;

  // Run in parallel — not sequential
  const [product, reviews, relatedProducts] = await Promise.all([
    firstValueFrom(productService.getProduct(id)),
    firstValueFrom(reviewService.getReviews(id)),
    firstValueFrom(productService.getRelated(id)),
  ]);

  return { product, reviews, relatedProducts };
};

// app.routes.ts
export const routes: Routes = [
  {
    path: 'products/:id',
    component: ProductDetailComponent,
    resolve: {
      product: productResolver,
      // or: data: productDetailResolver (combined)
    }
  }
];

// Component receives resolved data
// Angular 16+: pass resolve data as @Input() with withComponentInputBinding()
@Component({...})
export class ProductDetailComponent {
  // Signal input auto-bound from route resolve
  product = input<Product>();

  // Or use ActivatedRoute (older pattern)
  // private route = inject(ActivatedRoute);
  // product = toSignal(this.route.data.pipe(map(d => d['product'])));
}
Enter fullscreen mode Exit fullscreen mode

33. What is the difference between structural and attribute directives?

Difficulty: Senior

// --- Attribute Directive: modifies existing element ---
@Directive({
  selector: '[appHighlight]',
  standalone: true,
  host: {
    '(mouseenter)': 'onEnter()',
    '(mouseleave)': 'onLeave()',
  }
})
export class HighlightDirective {
  @Input('appHighlight') highlightColor = 'yellow';
  @Input() defaultColor = 'transparent';

  private el = inject(ElementRef<HTMLElement>);

  onEnter() {
    this.el.nativeElement.style.backgroundColor = this.highlightColor;
  }

  onLeave() {
    this.el.nativeElement.style.backgroundColor = this.defaultColor;
  }
}

// --- Structural Directive: changes DOM structure ---
@Directive({
  selector: '[appHasPermission]',
  standalone: true
})
export class HasPermissionDirective {
  private hasView = false;
  private authService = inject(AuthService);
  private vcr = inject(ViewContainerRef);
  private template = inject(TemplateRef<any>);

  @Input() set appHasPermission(permission: string) {
    const hasPermission = this.authService.can(permission);

    if (hasPermission && !this.hasView) {
      this.vcr.createEmbeddedView(this.template);
      this.hasView = true;
    } else if (!hasPermission && this.hasView) {
      this.vcr.clear();
      this.hasView = false;
    }
  }

  // Optional: else template
  @Input() set appHasPermissionElse(elseTemplate: TemplateRef<any>) {
    // Show else template when permission denied
  }
}

// Usage
<button *appHasPermission="'user:delete'">Delete</button>
<div [appHighlight]="'lightblue'" defaultColor="white">Hover me</div>

// --- The * shorthand desugars to ng-template ---
// <div *appHasPermission="'admin'"> → compiles to:
// <ng-template [appHasPermission]="'admin'"><div></div></ng-template>
Enter fullscreen mode Exit fullscreen mode

34. How do you prevent double HTTP calls with Angular SSR?

Difficulty: Expert

// app.config.ts — automatic TransferState with withFetch()
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(
      withFetch(),  // uses fetch API + automatically integrates with TransferState
    ),
    provideClientHydration(), // enables hydration + TransferState
    provideRouter(routes, withComponentInputBinding()),
  ]
};

// app.config.server.ts — server-specific config
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';

export const serverConfig: ApplicationConfig = mergeApplicationConfig(appConfig, {
  providers: [provideServerRendering()]
});

// HTTP calls in services are automatically deduplicated — no extra code needed
@Injectable({ providedIn: 'root' })
export class ProductService {
  private http = inject(HttpClient);

  getProducts(): Observable<Product[]> {
    // withFetch() + provideClientHydration() handles TransferState automatically
    // Server: fetches data, stores in TransferState, serialises to HTML
    // Client: reads from TransferState, skips HTTP call
    return this.http.get<Product[]>('/api/products');
  }
}

// --- Manual TransferState for non-HTTP data ---
import { TransferState, makeStateKey, isPlatformServer } from '@angular/core';
import { PLATFORM_ID } from '@angular/core';

const FEATURED_KEY = makeStateKey<FeaturedContent>('featured-content');

@Injectable({ providedIn: 'root' })
export class FeaturedService {
  private transferState = inject(TransferState);
  private platformId = inject(PLATFORM_ID);

  async getFeatured(): Promise<FeaturedContent> {
    if (this.transferState.hasKey(FEATURED_KEY)) {
      // Client: retrieve from TransferState (no extra fetch)
      const data = this.transferState.get(FEATURED_KEY, null)!;
      this.transferState.remove(FEATURED_KEY); // clean up
      return data;
    }

    // Server: fetch and store for client
    const data = await firstValueFrom(this.http.get<FeaturedContent>('/api/featured'));

    if (isPlatformServer(this.platformId)) {
      this.transferState.set(FEATURED_KEY, data);
    }

    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing


35. What is TestBed and how do you configure it?

Difficulty: Senior

import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';

describe('UserProfileComponent', () => {
  let fixture: ComponentFixture<UserProfileComponent>;
  let component: UserProfileComponent;
  let mockUserService: jasmine.SpyObj<UserService>;

  beforeEach(async () => {
    // Create typed spy object
    mockUserService = jasmine.createSpyObj('UserService', ['getUser', 'updateUser'], {
      currentUser$: new BehaviorSubject<User | null>(mockUser)
    });
    mockUserService.getUser.and.returnValue(of(mockUser));

    await TestBed.configureTestingModule({
      // For standalone component: import it
      imports: [
        UserProfileComponent,
        ReactiveFormsModule,
      ],
      providers: [
        // Override real service with spy
        { provide: UserService, useValue: mockUserService },
        { provide: ActivatedRoute, useValue: { snapshot: { params: { id: '1' } } } },
        provideRouter([]),
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(UserProfileComponent);
    component = fixture.componentInstance;

    // Set inputs before first change detection
    component.userId = '1';

    // Trigger ngOnInit and initial rendering
    fixture.detectChanges();
  });

  it('should display user name', () => {
    const nameEl = fixture.nativeElement.querySelector('[data-testid="user-name"]');
    expect(nameEl.textContent.trim()).toBe('Alice Johnson');
  });

  it('should call updateUser on form submit', fakeAsync(() => {
    mockUserService.updateUser.and.returnValue(of({ success: true }));

    const nameInput = fixture.nativeElement.querySelector('[data-testid="name-input"]');
    nameInput.value = 'Alice Smith';
    nameInput.dispatchEvent(new Event('input'));

    const submitBtn = fixture.nativeElement.querySelector('[data-testid="submit-btn"]');
    submitBtn.click();

    tick(); // resolve any microtasks/timers
    fixture.detectChanges();

    expect(mockUserService.updateUser).toHaveBeenCalledWith(
      jasmine.objectContaining({ name: 'Alice Smith' })
    );
  }));

  afterEach(() => {
    fixture.destroy(); // clean up component
  });
});
Enter fullscreen mode Exit fullscreen mode

36. How do you test components with HTTP dependencies?

Difficulty: Senior

import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ProductService]
    });
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should fetch products with correct params', () => {
    const mockProducts: Product[] = [
      { id: '1', name: 'Widget', price: 9.99 },
      { id: '2', name: 'Gadget', price: 19.99 },
    ];

    service.getProducts({ category: 'electronics', page: 1 }).subscribe(products => {
      expect(products.length).toBe(2);
      expect(products[0].name).toBe('Widget');
    });

    const req = httpMock.expectOne(r =>
      r.url === '/api/products' &&
      r.params.get('category') === 'electronics' &&
      r.params.get('page') === '1'
    );
    expect(req.request.method).toBe('GET');
    req.flush(mockProducts); // trigger the response
  });

  it('should handle 404 errors gracefully', () => {
    service.getProduct('non-existent').subscribe({
      next: () => fail('should have failed'),
      error: (err) => {
        expect(err.status).toBe(404);
        expect(err.error.message).toBe('Product not found');
      }
    });

    const req = httpMock.expectOne('/api/products/non-existent');
    req.flush({ message: 'Product not found' }, { status: 404, statusText: 'Not Found' });
  });

  it('should retry on 503', fakeAsync(() => {
    let callCount = 0;

    service.getProducts({}).subscribe();

    // First call fails
    let req = httpMock.expectOne('/api/products');
    req.flush('', { status: 503, statusText: 'Service Unavailable' });
    tick(1000); // wait for retry delay

    // Second call succeeds
    req = httpMock.expectOne('/api/products');
    req.flush([]);
  }));

  afterEach(() => httpMock.verify()); // no unexpected requests
});
Enter fullscreen mode Exit fullscreen mode

37. How do you test Signal-based components and async operations?

Difficulty: Advanced

describe('SignalCounterComponent', () => {
  let fixture: ComponentFixture<SignalCounterComponent>;
  let component: SignalCounterComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [SignalCounterComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(SignalCounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  // Signals are synchronous — direct signal manipulation
  it('should update DOM immediately when signal changes', () => {
    expect(fixture.nativeElement.querySelector('.count').textContent).toBe('0');

    component.count.set(42);
    fixture.detectChanges(); // needed to flush to DOM

    expect(fixture.nativeElement.querySelector('.count').textContent).toBe('42');
  });

  it('should compute derived values correctly', () => {
    component.price.set(100);
    component.quantity.set(3);

    expect(component.total()).toBe(300); // computed — synchronous
  });

  // fakeAsync for timer-based code
  it('should auto-increment with timer', fakeAsync(() => {
    component.startAutoIncrement();

    tick(1000); // advance 1 second
    fixture.detectChanges();
    expect(component.count()).toBe(1);

    tick(2000); // advance 2 more seconds
    fixture.detectChanges();
    expect(component.count()).toBe(3);

    component.stopAutoIncrement();
    discardPeriodicTasks(); // cleanup remaining intervals
  }));

  // Testing toSignal with Observable
  it('should display streamed data', fakeAsync(() => {
    const subject = new Subject<string>();
    const mockService = { data$: subject.asObservable() };
    component['dataService'] = mockService as any;

    subject.next('Hello');
    flushMicrotasks();
    fixture.detectChanges();

    expect(component.data()).toBe('Hello');
  }));
});
Enter fullscreen mode Exit fullscreen mode

38. What is your strategy for end-to-end testing?

Difficulty: Expert

// cypress/e2e/checkout.cy.ts

describe('Checkout Flow', () => {
  beforeEach(() => {
    // Seed state via API, not UI
    cy.request('POST', '/api/test/seed', { user: 'testUser', cart: [{ id: 'p1', qty: 2 }] });
    cy.login(); // custom command that sets auth token

    // Intercept and mock API calls for determinism
    cy.intercept('GET', '/api/cart', { fixture: 'cart.json' }).as('getCart');
    cy.intercept('POST', '/api/orders', { fixture: 'order-success.json' }).as('placeOrder');
    cy.intercept('GET', '/api/payment/methods', { fixture: 'payment-methods.json' }).as('getPaymentMethods');

    cy.visit('/checkout');
    cy.wait('@getCart');
  });

  it('should complete checkout successfully', () => {
    // Use data-testid — resilient to CSS/structure changes
    cy.get('[data-testid="cart-summary"]').should('be.visible');
    cy.get('[data-testid="item-count"]').should('contain', '2');

    // Fill shipping form
    cy.get('[data-testid="shipping-name"]').type('Alice Johnson');
    cy.get('[data-testid="shipping-address"]').type('123 Main St');
    cy.get('[data-testid="shipping-city"]').type('New York');

    cy.get('[data-testid="continue-payment-btn"]').click();
    cy.wait('@getPaymentMethods');

    // Select payment method
    cy.get('[data-testid="payment-card"]').first().click();
    cy.get('[data-testid="place-order-btn"]').click();

    cy.wait('@placeOrder');

    // Verify success state
    cy.url().should('include', '/order-confirmation');
    cy.get('[data-testid="order-id"]').should('contain', 'ORD-');
    cy.get('[data-testid="success-banner"]').should('be.visible');
  });

  it('should handle payment failure gracefully', () => {
    cy.intercept('POST', '/api/orders', { statusCode: 402, body: { error: 'Payment declined' } }).as('failedOrder');
    cy.get('[data-testid="place-order-btn"]').click();
    cy.wait('@failedOrder');

    cy.get('[data-testid="error-message"]').should('contain', 'Payment declined');
    cy.url().should('include', '/checkout'); // stays on checkout page
  });
});

// cypress/support/commands.ts
Cypress.Commands.add('login', () => {
  cy.request('POST', '/api/auth/login', { email: 'test@example.com', password: 'password' })
    .its('body.token')
    .then(token => {
      window.localStorage.setItem('auth_token', token);
    });
});
Enter fullscreen mode Exit fullscreen mode

39. What are Component Harnesses?

Difficulty: Advanced

import { TestbedHarnessEnvironment, HarnessLoader } from '@angular/cdk/testing/testbed';
import { MatInputHarness } from '@angular/material/input/testing';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatDialogHarness } from '@angular/material/dialog/testing';

describe('UserFormComponent with Harnesses', () => {
  let loader: HarnessLoader;
  let fixture: ComponentFixture<UserFormComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [UserFormComponent, MatInputModule, MatButtonModule, NoopAnimationsModule]
    }).compileComponents();

    fixture = TestBed.createComponent(UserFormComponent);
    loader = TestbedHarnessEnvironment.loader(fixture);
    fixture.detectChanges();
  });

  it('should fill and submit form', async () => {
    // Get harnesses — stable even if HTML structure changes
    const nameInput = await loader.getHarness(MatInputHarness.with({ placeholder: 'Full name' }));
    const emailInput = await loader.getHarness(MatInputHarness.with({ placeholder: 'Email address' }));
    const roleSelect = await loader.getHarness(MatSelectHarness);
    const submitBtn = await loader.getHarness(MatButtonHarness.with({ text: 'Save User' }));

    // Interact with harnesses
    await nameInput.setValue('Alice Johnson');
    await emailInput.setValue('alice@example.com');
    await roleSelect.open();
    await roleSelect.clickOptions({ text: 'Admin' });

    expect(await submitBtn.isDisabled()).toBeFalse();
    await submitBtn.click();

    expect(mockService.save).toHaveBeenCalledWith(
      jasmine.objectContaining({ name: 'Alice Johnson', role: 'admin' })
    );
  });

  it('should validate email format', async () => {
    const emailInput = await loader.getHarness(MatInputHarness.with({ placeholder: 'Email address' }));
    await emailInput.setValue('not-an-email');
    await emailInput.blur();

    // Check error state through harness
    expect(await emailInput.isControlValid()).toBeFalse();
  });
});

// --- Writing a custom harness for your own component ---
import { ComponentHarness } from '@angular/cdk/testing';

export class ProductCardHarness extends ComponentHarness {
  static hostSelector = 'app-product-card';

  private title = this.locatorFor('[data-testid="product-title"]');
  private priceEl = this.locatorFor('[data-testid="product-price"]');
  private addBtn = this.locatorFor('[data-testid="add-to-cart-btn"]');

  async getTitle(): Promise<string> {
    return (await this.title()).text();
  }

  async getPrice(): Promise<number> {
    const text = await (await this.priceEl()).text();
    return parseFloat(text.replace(/[^0-9.]/g, ''));
  }

  async clickAddToCart(): Promise<void> {
    await (await this.addBtn()).click();
  }

  async isInStock(): Promise<boolean> {
    const host = await this.host();
    return host.hasClass('in-stock');
  }
}
Enter fullscreen mode Exit fullscreen mode

40. What tools beyond TestBed improve Angular testing ergonomics?

Difficulty: Senior

Jest Configuration

// jest.config.js
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterFramework: ['<rootDir>/setup-jest.ts'],
  testMatch: ['**/*.spec.ts'],
  collectCoverage: true,
  coverageThreshold: {
    global: { branches: 80, functions: 85, lines: 85, statements: 85 }
  },
  // Faster: run tests in parallel
  maxWorkers: '50%',
};

// setup-jest.ts
import 'jest-preset-angular/setup-jest';
Enter fullscreen mode Exit fullscreen mode

Spectator Examples

import { createComponentFactory, Spectator } from '@ngneat/spectator';
import { createSpyObject } from '@ngneat/spectator/jest';

describe('ProductListComponent — Spectator', () => {
  const createComponent = createComponentFactory({
    component: ProductListComponent,
    imports: [ReactiveFormsModule],
    mocks: [ProductService],    // auto-mocked
    detectChanges: false,       // manual control
  });

  let spectator: Spectator<ProductListComponent>;

  beforeEach(() => {
    spectator = createComponent({
      props: { category: 'electronics' }
    });

    const service = spectator.inject(ProductService);
    service.getProducts.and.returnValue(of(mockProducts));
    spectator.detectChanges();
  });

  it('should render product list', () => {
    expect(spectator.queryAll('[data-testid="product-card"]')).toHaveLength(3);
  });

  it('should filter by search term', () => {
    spectator.typeInElement('Widget', '[data-testid="search-input"]');
    expect(spectator.queryAll('.product-card')).toHaveLength(1);
  });

  it('should emit selection', () => {
    const spy = spyOn(spectator.component.selected, 'emit');
    spectator.click('[data-testid="product-card"]');
    expect(spy).toHaveBeenCalledWith(mockProducts[0]);
  });
});
Enter fullscreen mode Exit fullscreen mode

ng-mocks for Deep Component Testing

import { MockBuilder, MockRender, ngMocks } from 'ng-mocks';

describe('CheckoutComponent — ng-mocks', () => {
  // MockBuilder: auto-mocks all dependencies, keep only what you specify
  beforeEach(() => MockBuilder(CheckoutComponent)
    .keep(ReactiveFormsModule)
    .mock(CartService, { getCart: () => of(mockCart) })
    .mock(PaymentService)
  );

  it('should display cart items', () => {
    const fixture = MockRender(CheckoutComponent);
    const items = ngMocks.findAll('[data-testid="cart-item"]');
    expect(items.length).toBe(mockCart.items.length);
  });

  it('should disable submit when cart is empty', () => {
    const fixture = MockRender(CheckoutComponent, { cartIsEmpty: true });
    const btn = ngMocks.find('[data-testid="submit-btn"]');
    expect(btn.attributes['disabled']).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

Quick Reference Summary

Topic Key Concept Code Pattern
Zone.js Monkey-patches async APIs ngZone.runOutsideAngular() + ngZone.run()
OnPush Only CD on reference change ChangeDetectionStrategy.OnPush
Signals Fine-grained reactivity signal(), computed(), effect()
Memory Leaks Auto-unsubscribe takeUntilDestroyed(), async pipe
switchMap Cancel previous Autocomplete, navigation
exhaustMap Ignore during active Login, form submit
concatMap Sequential queue Ordered saves, animations
mergeMap Parallel concurrent File uploads
OnPush + Signal No markForCheck needed Signals auto-trigger targeted re-render
DI Hierarchy Token resolution up tree @Self, @SkipSelf, @Optional
TestBed Angular test sandbox configureTestingModule()
Harnesses Stable component API loader.getHarness(MatButtonHarness)

Resources

Top comments (0)