Angular 21
Language
01

Getter vs Signal Computed

A class getter is re-evaluated on every change detection cycle regardless of whether its dependencies changed. A computed() signal memoizes the result and only recalculates when a tracked signal dependency changes.

✗ Anti-pattern TypeScript
// Called on EVERY change detection cycle — even when tags never changed
get hasFilterTag(): boolean {
  return this.tags.includes('filter'); // expensive if tags is large
}
✓ Best practice TypeScript
// Recalculates ONLY when filterTags signal changes
protected readonly hasFilterTag = computed(() =>
  this.filterTags().includes('filter'),
);
Live demo
CD triggers 0
Getter call count 0
Computed re-evaluations 1
Getter result false
Computed result false
Console output
[COMPUTED] filterTags changed -> re-evaluated -> false

Each CD trigger increments the getter call count. The computed re-evaluation count only increases when you "Toggle filter tag" - regardless of how many CD cycles occurred.

02

Lazy Loading Bundle size

loadChildren with NgModules requires shipping an entire module graph per route. loadComponent with standalone components produces a minimal, targeted chunk containing only that component and its direct imports - nothing more.

✗ Anti-pattern - NgModule TypeScript
// Old approach — requires a full NgModule wrapper
const routes: Routes = [{
  path: 'dashboard',
  loadChildren: () =>
    import('./dashboard/dashboard.module')
      .then(m => m.DashboardModule),
}];

// DashboardModule must declare and export DashboardComponent,
// pulling in the entire module graph even for a single page.
✓ Best practice - Standalone TypeScript
// Modern approach — load the standalone component directly
const routes: Routes = [{
  path: 'dashboard',
  loadComponent: () =>
    import('./dashboard/dashboard.component')
      .then(m => m.DashboardComponent),
}];

// No NgModule overhead. Angular splits exactly this component
// into its own chunk — smaller, faster initial load.
📄
Why it matters

An NgModule chunk includes every component declared in that module, whether the route uses them or not. Standalone lazy-loading lets the bundler apply tree-shaking at the component level - the chunk only contains what's directly imported by DashboardComponent. Typical savings: 20 - 60 KB per route for mid-size modules.

03

Signal Encapsulation Encapsulation

Exposing a WritableSignal publicly breaks encapsulation - any consumer can mutate state the service is supposed to own. Use .asReadonly() to expose a view-only projection while keeping mutation methods private to the service.

✗ Anti-pattern TypeScript
// Anti-pattern: exposing WritableSignal lets ANY consumer mutate state
@Injectable({ providedIn: 'root' })
export class UserService {
  readonly user: WritableSignal<User> = signal(null);
}

// Any component can now do:
this.userService.user.set({ name: 'Hacker' }); // uncontrolled mutation
✓ Best practice TypeScript
// Service owns mutations internally; exposes only a readonly view
@Injectable({ providedIn: 'root' })
export class UserService {
  private readonly _user = signal<User | null>(null);
  readonly user = this._user.asReadonly();

  loadUser(id: string): void {
    // Only this service can set _user
    this._user.set(/* fetched data */);
  }
}
🔒
Rule of thumb

Private _signal = signal() inside the service -> public signal = this._signal.asReadonly() for consumers. This is the same principle as a private setter with a public getter in traditional OOP, but enforced at the type level by Angular's signal API.

04

Service Scope DI

providedIn: 'root' creates a singleton that lives for the entire app lifetime. For ephemeral state - like a multi-step wizard - use component-level providers so the service is created fresh when the component mounts and garbage-collected when it is destroyed.

✗ Anti-pattern - global singleton TypeScript
// providedIn: 'root' creates a singleton that lives for the entire app.
// Fine for global services, but wrong for wizard/form state.
@Injectable({ providedIn: 'root' })
export class WizardStateService {
  currentStep = signal(0);
  formData = signal({});
  // Lives forever — state leaks between wizard sessions!
}
✓ Best practice - component-scoped TypeScript
// Component-level provider: created when component mounts,
// destroyed when it unmounts. State resets automatically.
@Component({
  selector: 'app-wizard',
  providers: [WizardStateService], // scoped to this component subtree
  templateUrl: './wizard.component.html',
  styleUrl: './wizard.component.scss',
})
export class WizardComponent {
  // WizardStateService is injected as a fresh instance here
  private readonly state = inject(WizardStateService);
}
Lifecycle alignment

Component-scoped providers are tied to the component's destroy lifecycle. This means no manual reset logic, no stale state between wizard sessions, and automatic memory reclamation - Angular handles it for you. All child components of the wizard also receive the same scoped instance.

05

Input Debounce RxJS

Without debounceTime, every keystroke fires a network request. With debounceTime(300) + distinctUntilChanged(), requests only fire after the user pauses - and only when the value actually changed.

✗ Anti-pattern TypeScript
// Every keystroke fires an HTTP request — no throttle, no dedup
onSearchInput(value: string): void {
  this.apiService.search(value).subscribe({
    next: results => this.results.set(results),
  });
}
✓ Best practice TypeScript
private readonly searchInput$ = new Subject<string>();

constructor() {
  this.searchInput$.pipe(
    debounceTime(300),           // wait 300ms after last keystroke
    distinctUntilChanged(),      // skip if value didn't change
    switchMap(q => this.api.search(q)),
    takeUntilDestroyed(),
  ).subscribe({
    next: results => this.results.set(results),
  });
}
Live demo - type something
X Without debounce - API calls fired 0
OK With debounce - API calls fired 0

Start typing to see requests logged here...

06

track in @for Performance

Without a proper track expression, Angular cannot identify which items changed and re-processes all N views on every list mutation. With track item.id, only genuinely new or updated items trigger DOM mutations.

✗ track $index HTML
// ❌ track $index — position as identity
@for (item of tasks(); track $index) {
  <app-task-card [task]="item" />
}

// On prepend, shuffle or mid-list removal:
// Angular re-processes ALL N views — O(n) DOM operations.
// DOM state (typed inputs, animations) does not follow the item.
✓ track item.id HTML
// ✅ track item.id — stable identity per item
@for (item of tasks(); track item.id) {
  <app-task-card [task]="item" />
}

// On prepend: Angular creates 1 new view — O(1) DOM creation.
// On shuffle: reorders DOM nodes without re-rendering — O(n) moves.
// DOM state correctly follows each item in both cases.
Live demo - watch the view re-process counter
track $index
DOM views processed5
Design tokens x1
Auth middleware x1
API rate limiting x1
Unit test coverage x1
Docker setup x1
track item.id
DOM views processed5
Design tokens x1
Auth middleware x1
API rate limiting x1
Unit test coverage x1
Docker setup x1

xN shows how many times each view was re-processed by Angular. On the track $index side, all items increment on every operation - even unchanged ones. On the track item.id side, only the new item starts at x1; existing items stay untouched.

💡
When track $index is acceptable

If the list is order-stable (never reordered, items only appended at the end, never removed from the middle) and items carry no internal DOM state (inputs, animations), $index is fine. In any other scenario - shuffle, prepend, arbitrary removal, pagination - prefer track item.id or another stable identity property.

07

Observable Cleanup RxJS

Subscriptions that outlive their component cause memory leaks and ghost event handlers. Angular 16+ ships takeUntilDestroyed() as the canonical solution - no boilerplate Subject, no ngOnDestroy required.

✗ Manual unsubscribe
// Manual unsubscribe — easy to forget, verbose
private sub!: Subscription;

ngOnInit(): void {
  this.sub = this.stream$.subscribe({ next: v => doWork(v) });
}

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

Verbose. Easy to forget when there are multiple subscriptions.

~ takeUntil pattern
// takeUntil pattern — better, but requires boilerplate Subject
private readonly destroy$ = new Subject<void>();

ngOnInit(): void {
  this.stream$
    .pipe(takeUntil(this.destroy$))
    .subscribe({ next: v => doWork(v) });
}

ngOnDestroy(): void {
  this.destroy$.next();
  this.destroy$.complete();
}

Better - but still requires a Subject and a full ngOnDestroy lifecycle hook.

✓ takeUntilDestroyed
// takeUntilDestroyed (Angular 16+) — no Subject, no ngOnDestroy
export class MyComponent {
  private readonly destroyRef = inject(DestroyRef);

  constructor() {
    // Works in inject() context — even outside ngOnInit
    this.stream$
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe({ next: v => doWork(v) });
  }
}

Zero boilerplate. Works in constructors and inject() context. Angular 16+.

Key advantage of takeUntilDestroyed

When called inside the constructor (or any function in the inject context), it automatically binds to the current component's DestroyRef without needing to pass it explicitly. You can also pass a destroyRef explicitly when calling from outside the inject context - for example, a standalone service method.

HttpClient requests do not need cleanup

Observables returned by HttpClient are finite - they emit a single response and then complete automatically, releasing all resources without memory leaks. Applying takeUntilDestroyed to them is harmless, but unnecessary. Reserve cleanup for long-lived streams: interval(), fromEvent(), WebSocket connections, and manually managed Subjects.

08

SharedModule Anti-pattern Architecture

The SharedModule pattern was a pragmatic solution in the NgModule world - declare once, export everywhere. With standalone components it becomes a liability: it forces every consumer to load components they never use, bloats chunk sizes, and creates invisible coupling through implicit re-exports.

✗ SharedModule TypeScript
// SharedModule bundles everything together — a common anti-pattern
@NgModule({
  declarations: [
    ButtonComponent, CardComponent, TableComponent,
    SpinnerComponent, TooltipComponent, // ... 30 more
  ],
  exports: [
    ButtonComponent, CardComponent, TableComponent,
    CommonModule, FormsModule, // ← forces every consumer
  ],                            // to load ALL of these
})
export class SharedModule {}
✓ Direct standalone imports TypeScript
// Standalone components: each imports ONLY what it needs
@Component({
  selector: 'app-dashboard',
  standalone: true,
  imports: [
    ButtonComponent,   // only these two are bundled
    CardComponent,
  ],
  templateUrl: './dashboard.component.html',
  styleUrl: './dashboard.component.scss',
})
export class DashboardComponent {}

// SpinnerComponent, TableComponent etc. are NOT included —
// they won't bloat this chunk at all.

SharedModule problems

  • All declared components included in every consumer's bundle
  • Implicit dependency - hard to know what a module actually needs
  • Single point of coupling - touching SharedModule risks regressions everywhere
  • Prevents effective tree-shaking by the bundler

Standalone benefits

  • Each component declares exactly what it needs - nothing more
  • Bundler can tree-shake at the component level
  • Dependencies are explicit and colocated with the component
  • Smaller chunks, faster lazy loading, easier refactoring
09

@defer Blocks Angular 17+

Eagerly loaded components bloat the initial bundle even when the user never scrolls to them. @defer performs automatic code-splitting for the deferred component - no bundler config, no loadComponent, just a template declaration.

✗ Eager loading HTML
// ❌ Eager loading — full bundle sent to the client
// even if the component is below the fold or rarely used
<app-heavy-chart [data]="analytics()" />
<app-rich-text-editor [content]="post()" />
<app-data-grid [rows]="rows()" />

// All JS/CSS for these components is downloaded on initial load.
// The user pays the cost even if they never scroll there.
✓ @defer with helper blocks HTML
// ✅ @defer — declarative lazy loading in the template
@defer (on viewport) {
  <app-heavy-chart [data]="analytics()" />
} @placeholder {
  <div class="chart-skeleton">Loading chart…</div>
} @loading (minimum 300ms) {
  <app-spinner />
} @error {
  <p>Failed to load the chart.</p>
}

// The heavy-chart JS is downloaded only when the block
// enters the viewport — no extra bundling config required.
All available triggersHTML
// Available triggers — can be combined

@defer (on idle) {      // when the browser is idle (requestIdleCallback)
@defer (on viewport) {   // when the placeholder enters the viewport
@defer (on interaction) { // on click/focus on the placeholder
@defer (on hover) {      // on mouse hover over the placeholder
@defer (on timer(2s)) {  // after 2 seconds from render
@defer (when isAdmin()) { // when the expression becomes truthy

// Combination: idle AND viewport
@defer (on idle; on viewport) {
  <app-analytics-dashboard />
}

// Eager prefetch — downloads JS without rendering yet
@defer (on interaction; prefetch on idle) {
  <app-rich-editor />
}
📦
Automatic code-splitting

The Angular CLI detects components inside @defer and automatically moves them into separate chunks at build time. No extra configuration needed - the same mechanism used by loadComponent in routes, but applicable to any part of the template. The @placeholder block renders immediately; the main content is downloaded and rendered only when the trigger fires.

When not to use @defer

Components above the fold, critical for LCP (Largest Contentful Paint), or that require immediate SSR hydration should not be deferred. Reserve @defer for secondary content: charts, rich-text editors, data grids, inactive tabs, and anything below the initial visible screen.

10

OnPush Change Detection Performance

With the default strategy (Default), Angular checks the template of every component on each change detection cycle - even when nothing relevant changed. OnPush puts the component to sleep: it only re-checks when an @Input reference changes, a Signal emits, or the async pipe receives a new value.

✗ ChangeDetectionStrategy.Default TypeScript
// ❌ Default — component re-checks on EVERY CD cycle
@Component({
  selector: 'app-dashboard',
  standalone: true,
  // changeDetection not set → Default is implicit
  templateUrl: './dashboard.component.html',
})
export class DashboardComponent {
  @Input() title = '';
  // Re-checks bindings even when title hasn't changed.
  // A setTimeout in a parent component already triggers this.
}
✓ ChangeDetectionStrategy.OnPush TypeScript
// ✅ OnPush — re-renders only when necessary
@Component({
  selector: 'app-dashboard',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './dashboard.component.html',
})
export class DashboardComponent {
  @Input() title = ''; // ✓ re-renders if reference changes
  count = signal(0);    // ✓ re-renders when signal changes
  data$ = this.svc.data$; // ✓ re-renders via async pipe
  // Anything else → component stays dormant.
}
Live demo - observe when each strategy re-renders
Default - renders0
OnPush - renders0
Signal version0
Trigger external CD - simulates setTimeout, HTTP response, parent event. Default re-renders. OnPush does not re-render.
Change Signal / @Input - both strategies re-render, because Angular knows the relevant state changed.

Press Trigger external CD several times and notice OnPush renders stays at zero - the component literally skips all binding checks.

OnPush + Signals = the ideal combination

With Signals, Angular knows exactly which node in the template changed and updates only that DOM node, without traversing the entire component tree. Declaring OnPush formalizes this in the component's CD strategy, eliminating unnecessary checks even in Zone.js apps. In components built exclusively on Signals, Angular can eventually skip CD entirely (Zoneless Architecture - Angular 18+).

11

Forms Strategy Bonus

Use Reactive Forms for complex, dynamic, or programmatically validated forms - validation lives in TypeScript, is easy to test, and composes well. Use Template-driven (ngModel) only for simple, isolated inputs where no cross-field validation or dynamic control generation is needed.

✗ Template-driven for complex forms HTML
// Template-driven for complex validation — hard to test, hard to compose
<form #f="ngForm" (ngSubmit)="submit(f)">
  <input name="email" [(ngModel)]="email"
         required email #emailCtrl="ngModel">
  <span *ngIf="emailCtrl.invalid && emailCtrl.touched">
    Invalid email <!-- validation logic hidden in template -->
  </span>
</form>
✓ Reactive form TypeScript
// Reactive form: validation in TypeScript, easy to test and compose
protected readonly form = this.fb.group({
  email: ['', [Validators.required, Validators.email]],
  username: ['', [Validators.required, Validators.minLength(3)]],
});

// Validation is explicit, type-safe, and trivially unit-testable.
// Use ngModel only for simple, standalone inputs.
Live reactive form
12

NgOptimizedImage Performance

A plain <img src> has no lazy loading, no intrinsic dimensions, and no fetch-priority hint — all of which hurt LCP and cause layout shift. The NgOptimizedImage directive fixes all three automatically.

✗ Plain img tagHTML
// Plain img — no lazy loading, no size hints, hurts LCP
<img src="/hero.jpg"
     alt="Hero banner">

// Browser fetches immediately (no priority control),
// no dimensions → layout shift (CLS).
✓ NgOptimizedImage directiveHTML
// NgOptimizedImage — lazy, sized, priority-aware
<img ngSrc="/hero.jpg"
     width="800" height="600"
     priority>

// LCP images: fetchpriority="high" + preload link.
// Others: loading="lazy". width/height prevent CLS.
Live demo — inspect generated attributes in DevTools → Elements
✗ plain &lt;img&gt;
Nature landscape
no loadingno width/heightno fetchpriority
City architecture
no loadingno width/heightno fetchpriority
Abstract technology
no loadingno width/heightno fetchpriority
✓ NgOptimizedImage
Nature landscape
fetchpriority="high"preload link injectedwidth + height ✓
City architecture
loading="lazy"width + height ✓
Abstract technology
loading="lazy"width + height ✓

Open DevTools → Elements and compare both sides. The plain images lack loading, width/height, and fetchpriority. The optimized images have loading="lazy" on below-fold images, fetchpriority="high" on the priority one (preventing layout shift and improving LCP).

13

Virtual Scroll Performance

Rendering thousands of items in the DOM at once causes massive layout thrashing and high memory usage. CDK cdk-virtual-scroll-viewport keeps only the visible rows in the DOM — list size no longer affects performance.

✗ All items in DOMHTML
// Renders ALL rows in the DOM immediately
@for (item of items; track item.id) {
  <div class="row">{{ item.label }}</div>
}
// 1 000 items → 1 000 DOM nodes in memory at once
✓ CDK Virtual ScrollHTML
// CDK Virtual Scroll — only visible rows in DOM
<cdk-virtual-scroll-viewport itemSize="40">
  <div *cdkVirtualFor="let item of items">
    {{ item.label }}
  </div>
</cdk-virtual-scroll-viewport>
// 1 000 items → ~10 DOM nodes regardless of size
Live demo — scroll both lists and compare
✗ @for — all items rendered 200 / 200 in DOM
#1Item #00014837
#2Item #00025576
#3Item #00039113
#4Item #00045699
#5Item #00056706
#6Item #00065653
#7Item #00075274
#8Item #00084769
#9Item #00092588
#10Item #00104207
#11Item #00114862
#12Item #00121506
#13Item #00134105
#14Item #00146183
#15Item #00159123
#16Item #00168543
#17Item #00171264
#18Item #00186854
#19Item #00191924
#20Item #00206944
#21Item #00218577
#22Item #00227705
#23Item #00234530
#24Item #00243104
#25Item #00251964
#26Item #00265328
#27Item #00272043
#28Item #00284642
#29Item #00299507
#30Item #00302065
#31Item #00316694
#32Item #00322479
#33Item #00331995
#34Item #00341670
#35Item #00353178
#36Item #00363165
#37Item #00377556
#38Item #00384218
#39Item #00398259
#40Item #00403614
#41Item #00412994
#42Item #00423170
#43Item #00436370
#44Item #00449423
#45Item #00454309
#46Item #00463893
#47Item #00476662
#48Item #00489967
#49Item #00492668
#50Item #00505836
#51Item #00511234
#52Item #00523583
#53Item #00531655
#54Item #00541678
#55Item #00554615
#56Item #00566640
#57Item #00571328
#58Item #00587393
#59Item #00591486
#60Item #00602251
#61Item #00611066
#62Item #00621056
#63Item #00634274
#64Item #00645278
#65Item #00659713
#66Item #00661890
#67Item #00676219
#68Item #00685676
#69Item #00697065
#70Item #00707561
#71Item #00715998
#72Item #00729975
#73Item #00732112
#74Item #00742052
#75Item #00752254
#76Item #00769594
#77Item #00774470
#78Item #00783059
#79Item #00798129
#80Item #00808529
#81Item #00815341
#82Item #00827371
#83Item #00837879
#84Item #00849556
#85Item #00858385
#86Item #00867549
#87Item #00872524
#88Item #00888540
#89Item #00895675
#90Item #00902492
#91Item #00918889
#92Item #00927822
#93Item #00938937
#94Item #00946319
#95Item #00954788
#96Item #00965206
#97Item #00979241
#98Item #00987596
#99Item #00993183
#100Item #01009416
#101Item #01014969
#102Item #01024385
#103Item #01037471
#104Item #01045458
#105Item #01057514
#106Item #01061219
#107Item #01074055
#108Item #01089435
#109Item #01094498
#110Item #01105303
#111Item #01114727
#112Item #01122743
#113Item #01139509
#114Item #01144495
#115Item #01151348
#116Item #01167766
#117Item #01177403
#118Item #01189984
#119Item #01195810
#120Item #01205927
#121Item #01211351
#122Item #01227654
#123Item #01239973
#124Item #01241614
#125Item #01253323
#126Item #01264953
#127Item #01274958
#128Item #01287159
#129Item #01291679
#130Item #01305015
#131Item #01319537
#132Item #01325280
#133Item #01337397
#134Item #01341168
#135Item #01355536
#136Item #01368059
#137Item #01376875
#138Item #01387672
#139Item #01396970
#140Item #01404515
#141Item #01418594
#142Item #01423920
#143Item #01437245
#144Item #01445360
#145Item #01454955
#146Item #01464704
#147Item #01478329
#148Item #01481358
#149Item #01497992
#150Item #01508702
#151Item #01511566
#152Item #01528644
#153Item #01533034
#154Item #01542740
#155Item #01558980
#156Item #01568706
#157Item #01575320
#158Item #01585845
#159Item #01593475
#160Item #01605662
#161Item #01615227
#162Item #01623665
#163Item #01636000
#164Item #01644995
#165Item #01652080
#166Item #01669533
#167Item #01677028
#168Item #01686379
#169Item #01693310
#170Item #01707271
#171Item #01717330
#172Item #01721078
#173Item #01737451
#174Item #01749837
#175Item #01758069
#176Item #01768035
#177Item #01779158
#178Item #01787117
#179Item #01799001
#180Item #01808508
#181Item #01813241
#182Item #01825969
#183Item #01832665
#184Item #01845871
#185Item #01854339
#186Item #01866282
#187Item #01879113
#188Item #01882363
#189Item #01897239
#190Item #01906163
#191Item #01917904
#192Item #01922657
#193Item #01933589
#194Item #01945809
#195Item #01952376
#196Item #01966354
#197Item #01976509
#198Item #01983721
#199Item #01998059
#200Item #02002479
✓ CDK Virtual Scroll — 1 000 items ~10 / 1000 in DOM

Open DevTools → Elements and count list rows on each side. The left renders 200 nodes eagerly (a real app with 1 000 would lock the browser). The CDK side renders only ~10 DOM nodes regardless of list length — scroll to item #1 000 instantly with no performance cost.

14

Pure Pipe Performance

Calling a method directly in a template expression re-executes it on every change detection cycle. A pure: true pipe is memoized by Angular — it only re-runs when the input reference changes.

✗ Method call in templateTypeScript
// Called on EVERY change detection cycle
<span>{{ formatPrice(item.price) }}</span>

formatPrice(price: number): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency', currency: 'USD',
  }).format(price);
}
✓ Pure pipeTypeScript
// Pure pipe — Angular memoizes by input reference
@Pipe({ name: 'formatPrice', pure: true })
export class FormatPricePipe {
  transform(price: number): string {
    return new Intl.NumberFormat('en-US', {
      style: 'currency', currency: 'USD',
    }).format(price);
  }
}
Live demo — watch call counts
CD triggers0
Method calls0
Pipe calls0
✗ method call
Angular Course$49.99
RxJS Guide$29.99
TypeScript Book$39.99
Design System$99.99
CLI Tools$19.99
✓ pure pipe
Angular Course$49.99
RxJS Guide$29.99
TypeScript Book$39.99
Design System$99.99
CLI Tools$19.99

Press Trigger CD: the method is called 5 times (once per product per cycle) while the pipe call count stays at zero — prices did not change so Angular skips re-evaluation. Press Change price: only 1 new pipe call fires (the changed item). The method always fires 5 times.

15

toSignal() Signals

toSignal() converts any Observable into a readable Signal. It automatically subscribes in the injection context and unsubscribes when the component is destroyed — eliminating manual subscription boilerplate.

✗ Manual subscribe()TypeScript
// Manual subscribe — boilerplate, easy to leak
export class ItemsComponent {
  items: Item[] = [];
  constructor() {
    inject(HttpClient).get<Item[]>('/api/items')
      .pipe(takeUntilDestroyed())
      .subscribe(data => this.items = data);
  }
}
✓ toSignal()TypeScript
// toSignal — one line, auto-unsubscribes, signal-reactive
export class ItemsComponent {
  readonly items = toSignal(
    inject(HttpClient).get<Item[]>('/api/items'),
    { initialValue: [] },
  );
}
16

effect() vs computed() Signals

Use computed() to derive values from signals — it is synchronous, memoized, and tracked. Use effect() only for side-effects such as logging, analytics, or DOM manipulation. Writing to a signal inside effect() to compute a value is an anti-pattern.

✗ effect() to derive stateTypeScript
// Wrong: effect() to derive state — async, causes extra render
readonly fullName = signal('');

effect(() => {
  this.fullName.set(
    this.first() + ' ' + this.last()
  );
}, { allowSignalWrites: true }); // ← flag required
✓ computed() + effect() for side-effectsTypeScript
// computed() — synchronous, memoized, no extra render
readonly fullName = computed(() =>
  this.first() + ' ' + this.last()
);

// effect() only for side-effects (logging, DOM sync, analytics)
effect(() => console.log('Name:', this.fullName()));
Live demo — observe evaluation order
Renders0
Effect runs1
Computed evaluations0
✗ effect()Alice SmithUpdated asynchronously after render — causes an extra render cycle
✓ computed()Alice SmithDerived synchronously during the same render — zero extra cycles
Evaluation log

Change a name to observe evaluation order...

Each name change: computed() evaluates once, synchronously, during rendering. The effect() runs after the render and writes to another signal — causing Angular to schedule a second render. Watch the Renders counter climb roughly twice as fast as Computed evaluations.

17

linkedSignal() Signals

linkedSignal() creates a signal that derives its default from another signal but remains writable. When the source changes, it resets to the new derived value — eliminating the verbose pattern of a separate signal plus an effect() to sync them.

✗ Signal + effect() to syncTypeScript
// Two separate signals + effect to sync — verbose, race-prone
readonly options = signal<string[]>([]);
readonly selected = signal<string | null>(null);

effect(() => {
  this.selected.set(this.options()[0] ?? null);
});
✓ linkedSignal()TypeScript
// linkedSignal — derived but writable; resets when source changes
readonly options = signal<string[]>([]);
readonly selected = linkedSignal(() => this.options()[0]);

// User can override: selected.set('other')
// When options changes, selected resets to options()[0]
18

Signal Input Signals

The @Input() decorator is not reactive — you need ngOnChanges to react to changes. The input() function returns a signal, making the input directly composable with computed() and effect() without any lifecycle hook.

✗ @Input() decoratorTypeScript
// @Input decorator — not reactive, needs ngOnChanges
@Input() title: string = '';
@Input() userId: number = 0;

ngOnChanges(): void {
  // re-derive state on every input change manually
  this.label = this.title.toUpperCase();
}
✓ input() signal functionTypeScript
// Signal input — reactive, composable with computed()
readonly title = input<string>('');
readonly userId = input<number>(0);

// Derived state updates automatically — no lifecycle hook
readonly label = computed(() => this.title().toUpperCase());
19

Facade Pattern Architecture

A component that injects multiple unrelated services becomes tightly coupled to every one of them. A Facade service aggregates and orchestrates those services internally — the component depends on a single, stable API.

✗ Multiple injected servicesTypeScript
// Component knows too much — coupled to 3 services
export class CheckoutComponent {
  private cart = inject(CartService);
  private user = inject(UserService);
  private inv  = inject(InventoryService);

  checkout() {
    this.inv.reserve(this.cart.items());
    this.cart.clear();
    this.user.addOrder(this.cart.total());
  }
}
✓ Single facade injectionTypeScript
// Component depends on one facade — orchestration is encapsulated
export class CheckoutComponent {
  private facade = inject(CheckoutFacade);

  checkout() { this.facade.checkout(); }
}

// CheckoutFacade orchestrates Cart, User, Inventory internally
20

Smart / Dumb Components Architecture

Smart (container) components fetch data and manage state. Dumb (presentational) components receive data via input() and emit events via output() — they are stateless, easily testable, and reusable.

✗ Dumb component fetches its own dataTypeScript
// "Dumb" component that fetches its own data — hard to test
export class UserCardComponent {
  user = toSignal(inject(HttpClient)
    .get<User>('/api/me'));
  // fetches on every instantiation, untestable in isolation
}
✓ Smart fetches, dumb rendersTypeScript
// Smart parent fetches; dumb child only renders
// Smart (container):
readonly user = toSignal(this.http.get<User>('/api/me'));
// template: <app-user-card [user]="user()" />

// Dumb (presentational):
readonly user = input<User | undefined>();
// pure display — no HTTP, easily unit-tested
21

InjectionToken Architecture

Hardcoding environment.apiUrl directly in a service creates an untestable, unswappable dependency. An InjectionToken makes the value part of Angular's DI graph — trivially overridable in tests and per-environment configurations.

✗ Hardcoded environment referenceTypeScript
// Hardcoded env reference — untestable, not swappable
@Injectable({ providedIn: 'root' })
export class ApiService {
  private base = environment.apiUrl;

  getUsers() {
    return this.http.get(`${this.base}/users`);
  }
}
✓ InjectionTokenTypeScript
// InjectionToken — testable, overridable per environment
export const API_URL = new InjectionToken<string>('API_URL', {
  factory: () => inject(ENVIRONMENT).apiUrl,
});

export class ApiService {
  private base = inject(API_URL);
}
22

Folder Structure Architecture

Organizing by type (components/, services/, pipes/) mixes unrelated features and makes it hard to reason about or delete a feature. Organizing by feature keeps everything cohesive — each folder is self-contained and independently removable.

✗ Type-based structureFolder tree
src/app/
├── components/
│   ├── header.component.ts
│   ├── user-card.component.ts
│   └── product-card.component.ts
├── services/
│   ├── auth.service.ts
│   └── cart.service.ts
└── pipes/
    └── format-price.pipe.ts
✓ Feature-based structureFolder tree
src/app/
├── feature/
│   ├── auth/
│   │   ├── auth.service.ts
│   │   └── login.component.ts
│   └── cart/
│       ├── cart.service.ts
│       └── cart.component.ts
└── shared/
    └── format-price.pipe.ts
23

bypassSecurityTrust Security

Calling bypassSecurityTrustHtml(userInput) disables Angular's built-in XSS sanitizer. Use [innerHTML] directly for user-provided content — Angular sanitizes it automatically. Only bypass sanitization for HTML you generate and control on the server.

✗ bypassSecurityTrustHtml(userInput)TypeScript
// XSS risk — bypasses Angular's sanitizer entirely
export class CommentComponent {
  private sanitizer = inject(DomSanitizer);

  safeHtml = this.sanitizer
    .bypassSecurityTrustHtml(userInput);
  // if userInput contains <script>, it will execute
}
✓ [innerHTML] with auto-sanitizationTypeScript
// Angular auto-sanitizes [innerHTML] — safe by default
<div [innerHTML]="userContent"></div>

// Only bypass for HTML sourced from your own trusted server:
safeHtml = this.sanitizer
  .bypassSecurityTrustHtml(serverRenderedHtml);
// NEVER call this with direct user input
25

HttpClient Generics TypeScript

Calling http.get() without a type parameter returns Observable<Object> — all type safety is lost and consumers must cast manually. Providing the generic http.get<User[]>() gives you full IntelliSense, compile-time checks, and safe refactoring.

✗ http.get() — untypedTypeScript
// Returns Observable<Object> — no type safety
getUsers() {
  return this.http.get('/api/users');
  // consumers must cast: (response as User[])
  // typos in property names compile with no error
}
✓ http.get<T>() — typedTypeScript
// Returns Observable<User[]> — fully typed
getUsers(): Observable<User[]> {
  return this.http.get<User[]>('/api/users');
  // IntelliSense, refactoring, and compile-time checks work
}
26

output() Function TypeScript

The @Output() decorator with EventEmitter is the classic approach but is verbose and class-based. The output() function is the modern equivalent — no decorator, no import of EventEmitter, and it pairs naturally with signal inputs.

✗ @Output() EventEmitterTypeScript
// Decorator-based — verbose, class-based EventEmitter
@Output() selected = new EventEmitter<Item>();
@Output() removed = new EventEmitter<string>();

select(item: Item) { this.selected.emit(item); }
✓ output() functionTypeScript
// Function-based output — no decorator, no EventEmitter import
readonly selected = output<Item>();
readonly removed  = output<string>();

select(item: Item) { this.selected.emit(item); }
27

Type Guards TypeScript

Using any silences the TypeScript compiler entirely — property access is unchecked and runtime crashes go undetected at compile time. unknown forces you to narrow the type before use, keeping the type system engaged and your code safe.

✗ function process(value: any)TypeScript
// any disables all type checking — unsafe property access
function process(value: any) {
  console.log(value.name); // no error even if undefined
  value.nonExistent();   // runtime crash, not compile error
}
✓ unknown + type guard narrowingTypeScript
// unknown forces explicit narrowing before use
function isUser(v: unknown): v is User {
  return typeof v === 'object' && v !== null
    && 'name' in v;
}
function process(value: unknown) {
  if (isUser(value)) console.log(value.name); // safe
}
28

inject() Function DX

Constructor injection requires listing all dependencies as constructor parameters — verbose and hard to refactor. The inject() function declares dependencies as class fields, is easier to read, and also works in standalone functions and composable helpers outside the class.

✗ Constructor injectionTypeScript
// Constructor injection — all deps in one long signature
constructor(
  private svc: MyService,
  private router: Router,
  private http: HttpClient,
) {}
✓ inject() at field levelTypeScript
// inject() — field-level, composable, works in functions
private readonly svc    = inject(MyService);
private readonly router = inject(Router);
private readonly http   = inject(HttpClient);

// Also usable in standalone functions / composable helpers
29

Route Input Binding DX

Reading route parameters via ActivatedRoute.snapshot is imperative and verbose. With withComponentInputBinding() enabled in the router config, Angular binds route params, query params, and resolve data directly to input() signals — zero boilerplate.

✗ ActivatedRoute.snapshotTypeScript
// Manual ActivatedRoute snapshot — verbose, imperative
export class DetailComponent {
  private route = inject(ActivatedRoute);
  id!: string;
  ngOnInit() {
    this.id = this.route.snapshot.params['id'];
  }
}
✓ withComponentInputBinding() + input()TypeScript
// app.config.ts: provideRouter(routes, withComponentInputBinding())
// Route: { path: 'detail/:id', component: DetailComponent }
export class DetailComponent {
  // Angular binds :id param automatically — no ActivatedRoute
  readonly id = input<string>();
}
30

bootstrapApplication() DX

The classic AppModule with @NgModule({ bootstrap }) is boilerplate that serves no purpose in an all-standalone app. bootstrapApplication() bootstraps the root component directly with a flat providers array — no module wrapper needed.

✗ AppModule with @NgModuleTypeScript
// NgModule boilerplate — required in Angular <14
@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, HttpClientModule],
  bootstrap: [AppComponent],
})
export class AppModule {}
✓ bootstrapApplication()TypeScript
// Standalone bootstrap — no NgModule at all
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
    provideAnimations(),
  ],
});
31

RxJS Flattening Operators RxJS

Choosing the wrong flattening operator silently breaks behavior. switchMap cancels the previous inner observable — ideal for search, catastrophic for saves. concatMap queues emissions — correct for ordered writes. mergeMap runs concurrently — good for parallel reads.

✗ switchMap for writes (cancels saves)TypeScript
// switchMap for a save — cancels in-flight requests!
saveBtn$.pipe(
  switchMap(() => this.http.post('/save', data)),
).subscribe();
// If the user clicks twice, the first POST is cancelled
// → data may never be persisted
ⓘ mergeMap — concurrent readsTypeScript
// mergeMap — concurrent, good for parallel reads
ids$.pipe(
  mergeMap(id => this.http.get(`/item/${id}`)),
).subscribe();
// All requests run in parallel — order not guaranteed
// Ideal for independent, parallel fetches
✓ Right operator for the right jobTypeScript
// switchMap for search; concatMap for ordered writes
search$.pipe(
  switchMap(q => this.http.get(`/search?q=${q}`)),
).subscribe(); // cancels stale searches ✓

saveBtn$.pipe(
  concatMap(() => this.http.post('/save', data)),
).subscribe(); // queues saves, none are lost ✓
32

combineLatest / withLatestFrom RxJS

Nesting subscribe() inside subscribe() creates callback hell, memory leaks, and impossible cleanup. combineLatest() emits whenever any source emits. withLatestFrom() samples a secondary stream only when the primary emits — both are flat and composable.

✗ Nested subscribe()TypeScript
// Nested subscribes — callback hell, hard to clean up
this.user$.subscribe(user => {
  this.prefs$.subscribe(prefs => {
    // inner subscription leaks if outer completes first
    this.render(user, prefs);
  });
});
✓ combineLatest() / withLatestFrom()TypeScript
// combineLatest — emits whenever either source emits
combineLatest([this.user$, this.prefs$]).pipe(
  takeUntilDestroyed(),
).subscribe(([user, prefs]) => this.render(user, prefs));

// withLatestFrom — only user$ drives; prefs sampled silently
this.user$.pipe(withLatestFrom(this.prefs$));
33

Higher-Order Observables RxJS

Subscribing inside a subscribe() callback creates deeply nested, unmanageable code with no way to cancel inner subscriptions when the outer completes. Higher-order operators like switchMap flatten the stream into a single subscription that is automatically managed.

✗ Nested subscribe() inside subscribe()TypeScript
// Nested subscribes — callback hell, impossible to cancel
this.route.params.subscribe(params => {
  this.http.get(`/user/${params['id']}`)
    .subscribe(user => {
      // inner sub leaks; no way to cancel outer cleanly
      this.user = user;
    });
});
✓ switchMap() flat pipelineTypeScript
// Flat pipeline — single subscription, auto-cancels inner
this.route.params.pipe(
  switchMap(params =>
    this.http.get(`/user/${params['id']}`)
  ),
  takeUntilDestroyed(),
).subscribe(user => this.user = user);