Angular Senior Developer — Interview Questions & Detailed Solutions
Table of Contents
Change Detection
- What is Zone.js and how does Angular use it?
- OnPush change detection strategy
- Signals vs Zone.js change detection
- detach() vs markForCheck()
Signals
- signal(), computed(), effect() primitives
- Signal and RxJS interop
- Signal inputs and model()
- Zoneless Angular
- Signals with OnPush
Performance
- trackBy and @for track
- Lazy loading strategies
- Non-destructive hydration
- Bundle size analysis
- NgOptimizedImage
- @defer triggers and states
RxJS
- switchMap vs mergeMap vs concatMap vs exhaustMap
- Avoiding memory leaks
- Error handling without killing a stream
- Custom RxJS operators
- Subject variants
- Async pipe advantages
Architecture
- Hierarchical dependency injection
- Angular 17+ architecture without NgModules
- Signals vs NgRx for state management
- Functional HTTP interceptors
- Micro frontends with Module Federation
- Functional route guards
- Pure vs impure pipes
- InjectionToken for configuration
- Multi-slot content projection
- Dynamic component creation
- Route resolve data strategy
- Structural vs attribute directives
- SSR and TransferState
Testing
- TestBed configuration
- Testing with HTTP dependencies
- Testing Signal-based components
- E2E testing strategy
- Component Harnesses
- 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
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);
}
}
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);
});
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
-
isStableobservable 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()+ explicitngZone.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:
- An
@Input()reference changes (shallow comparison — not deep mutation) - An event originates from the component or its children
- An Observable bound with the
asyncpipe emits a new value -
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
}
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
}
}
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
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 calldetectChanges()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
}
}
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
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
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
}
}
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();
});
}
}
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]
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');
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
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));
});
}
}
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;
});
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 });
}
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)); }
}
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]);
}
}
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" />
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);
}
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
}
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 -
asyncpipe 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(),
]
};
// angular.json — remove zone.js from polyfills
{
"projects": {
"my-app": {
"architect": {
"build": {
"options": {
"polyfills": [] // remove "zone.js"
}
}
}
}
}
}
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');
}
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
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
}
}
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
}
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
});
}
}
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
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,
]
}
];
@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 />
}
`
})
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
)
]
};
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()]
});
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
}
}
}
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
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';
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"
}
]
}
}
}
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')
]
};
@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 = [...];
}
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); }
}
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)
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']));
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
}
}
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
})
);
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);
});
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
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))
);
}
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) {}
}
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) },
]
}
];
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/
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
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
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])
)
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 */ }
})
]
};
// 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' })
)
);
}
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'),
}
];
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"
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);
}
}
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>
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');
}
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'])));
}
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>
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;
}
}
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
});
});
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
});
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');
}));
});
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);
});
});
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');
}
}
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';
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]);
});
});
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();
});
});
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) |
Top comments (0)