Angular fundamentals

The rest of this showcase demonstrates senior patterns; this page is the map of what sits under them — the building blocks every one of those patterns is made of. Each fundamental below is its own section: what it is in a line, a small live example or a deep link to where the app already leans on it, and the real source. It's version-agnostic Angular 21 — the APIs you'll reach for on any project, not this quarter's headline feature.

The map

Seven fundamentals already have a page of their own — they're listed here so the map is complete, then the sections below cover the rest with live examples or annotated source.

  • Components — standalone, OnPush, signal inputs.
  • Signalssignal/computed/linkedSignal in the deferrable-views demo.
  • Control flow@if/@for/@switch throughout, the grid most of all.
  • Forms — typed reactive forms, validators, a wizard.
  • RxJS — typeahead, polling, flattening, retry/backoff.
  • Change detection — OnPush and zoneless, measured live.
  • Testing — Vitest, TestBed, fake timers, a11y assertions.

Template & data binding

The four bindings every template is built from: {{ interpolation }}, [property], (event), and the [class]/[style] pair. Type below and toggle the highlight — the same value flows through all four.

Interpolated: edit me

Two-way binding

[(value)] is sugar for a property binding plus an event binding — Angular's "banana in a box". Built on a model() signal (or a ControlValueAccessor), it's how a control both shows a value and reports changes. The rating control on the forms page is the real one: a custom CVA you bind exactly like a native input.

See the rating control (CVA + model) →

Pipes

A pipe is the small, pure transformation that belongs in the template. Built-ins handle the common cases — here date, number, and currency format one value three ways — and a custom pipe handles the rest.

  • | date → Jul 2, 2026, 3:10:51 PM
  • | number → 1,234,567.89
  • | currency → $1,234,567.89
  • | relativeTime → 2m ago (custom, pure)
Pipe vs. directive
Both keep logic out of components, but a pipe transforms a value for display while a directive changes an element's behaviour. The [fooAmount] directive is the same accounting format as a pipe could do — chosen as a directive because it also sets the element's colour and aria, which a pipe can't reach.
View source — a custom pure piperelative-time.pipe.ts

Pure by default: Angular only re-runs it when the input reference changes, which is what makes a pipe cheap to call in a hot template. The doc comment weighs that against the impure alternative.

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

/*
 * A custom pipe — the small, pure transformation that belongs in the template, not the
 * component. "How long ago was this?" is presentation, derived purely from an input, so it's a
 * pipe rather than a computed field cluttering every component that shows a timestamp.
 *
 * Pipes are pure by default, and that default is load-bearing: Angular only re-runs a pure pipe
 * when its input *reference* changes, so it's cheap to call in a hot template. The cost is that a
 * pure pipe can't react to the clock moving on its own — its input didn't change, so it isn't
 * re-evaluated. For a relative-time string that's usually fine (the surrounding view re-renders
 * often enough), and far cheaper than the impure alternative below, which Angular re-runs on
 * every change-detection pass. Reach for `pure: false` only when the output genuinely can't be a
 * function of the input alone — and know you're paying for it each tick.
 */
@Pipe({ name: 'relativeTime' })
export class RelativeTimePipe implements PipeTransform {
  transform(value: Date | string | number, now: Date = new Date()): string {
    const then = value instanceof Date ? value : new Date(value);
    const seconds = Math.round((now.getTime() - then.getTime()) / 1000);

    if (seconds < 5) return 'just now';
    if (seconds < 60) return `${seconds}s ago`;

    const minutes = Math.round(seconds / 60);
    if (minutes < 60) return `${minutes}m ago`;

    const hours = Math.round(minutes / 60);
    if (hours < 24) return `${hours}h ago`;

    const days = Math.round(hours / 24);
    return `${days}d ago`;
  }
}

Async pipe

| async subscribes to an Observable in the template and — the part that earns it — unsubscribes automatically when the view is destroyed, so a stream can't outlive the component that showed it. The clock below is an interval stream rendered straight through it.

Now: 3:12:26 PM

More streams on the RxJS page →

Custom structural directive

An attribute directive decorates an element that exists; a structural one decides whether — and how many times — it exists at all. *fooRepeat="n" is authored from scratch with TemplateRef + ViewContainerRef, the same primitives @for and @if are built on. Drag the count:

  • Row 1
  • Row 2
View source — the *fooRepeat structural directiverepeat.directive.ts

It reconciles instead of rebuilding — adding only new views and removing only the surplus — so it never throws away view state on a count change. The context object is what powers the let i = index binding.

ts
import {
  Directive,
  Input,
  TemplateRef,
  ViewContainerRef,
  inject,
} from '@angular/core';

/*
 * A structural directive authored from scratch — the counterpart to an attribute directive.
 * An attribute directive decorates an element that already exists; a structural one decides
 * whether (and how many times) an element exists at all. `*fooRepeat="n"` stamps its template
 * `n` times:
 *
 *   <li *fooRepeat="3">row</li>   →  three <li>s
 *
 * The star is sugar. `*fooRepeat="n"` desugars to an <ng-template> wrapping the host element,
 * and the directive is handed two tools: a `TemplateRef` (the blueprint of what to stamp) and a
 * `ViewContainerRef` (the slot in the DOM to stamp into). That separation is the whole idea —
 * the directive never touches the host element directly; it asks the container to create or
 * clear views built from the template. Everything @for and @if do is this, one layer down.
 *
 * The context object passed to `createEmbeddedView` is what powers the `let` bindings in a
 * template (`*fooRepeat="3; let i = index"`). We expose a zero-based `index` and the `$implicit`
 * value so `let i` with no key binds to it — the same contract `@for`'s `$index` rides on.
 */
@Directive({
  selector: '[fooRepeat]',
})
export class RepeatDirective {
  private readonly template = inject<TemplateRef<RepeatContext>>(TemplateRef);
  private readonly viewContainer = inject(ViewContainerRef);

  private current = 0;

  /*
   * Reconcile rather than rebuild. When the count drops we remove only the surplus views from
   * the end; when it grows we create only the new ones. Tearing the whole container down and
   * restamping on every change would be correct but wasteful — and it would throw away view
   * state (focus, form values) the user might care about. Same instinct as a keyed @for.
   */
  @Input() set fooRepeat(count: number) {
    const next = Math.max(0, Math.floor(count ?? 0));
    if (next > this.current) {
      for (let i = this.current; i < next; i++) {
        this.viewContainer.createEmbeddedView(this.template, { $implicit: i, index: i });
      }
    } else if (next < this.current) {
      for (let i = this.current; i > next; i--) {
        this.viewContainer.remove(i - 1);
      }
    }
    this.current = next;
  }

  /*
   * The template type-guard that lets `let i = index` type-check. Angular's language service
   * reads this static method to know the shape of the context, so the `let` bindings aren't
   * `any` in strict templates — the structural-directive equivalent of typing an @Input.
   */
  static ngTemplateContextGuard(
    _dir: RepeatDirective,
    ctx: unknown,
  ): ctx is RepeatContext {
    return true;
  }
}

export interface RepeatContext {
  readonly $implicit: number;
  readonly index: number;
}

Directives

A component is itself a directive — one with a template of its own, rendering a self-contained piece of UI; the design-system catalogue is full of them. But a component is only one of the three kinds of directive Angular gives you, and reaching for it by reflex is how a design system ends up wrapping every native element in a custom tag it didn't need. The other two earn their place. A structural directive changes the shape of the DOM — it adds or removes whole subtrees, which is what @if and @for now do in the framework itself, and what *fooRepeat above does by hand. An attribute directive changes an element that's already there: it attaches behaviour, ARIA, or presentation to a host without introducing a wrapper. [fooTooltip] in the design system is one; so is the [fooAmount] below.

Attribute directives compose in a way components can't. Through host bindings a directive writes classes, styles, attributes, and listeners straight onto whatever element it sits on — and signal inputs make those bindings reactive, recomputing the moment an input changes, the same model the components use. hostDirectives takes it further: a component can declare a list of directives to apply to itself, inheriting their behaviour and even re-exposing their inputs, so shared concerns — a focus ring, an analytics hook, an accessibility contract — live in one directive and get pulled into many components by composition rather than copy-paste.

The judgement call worth naming is directive versus pipe. A pipe is the right tool when all you're doing is transforming a value for display — a currency pipe in an interpolation is exactly that, and a directive there would be overkill. [fooAmount] is the case where the line is crossed: it doesn't only format the number into accounting-style USD, it also colours a debit, flags the host with .is-negative, and writes an aria-label that speaks the sign aloud. Formatting and presentation, bound to the host in one place — and a pipe can only return a string, it can't touch the element it renders into. The moment the answer has to reach the host, it stopped being a pipe.

[fooAmount]="1284.5"$1,284.50
[fooAmount]="-234"($234.00)
[fooAmount]="0"$0.00
View sourceamount.ts

The whole directive: a signal input for the value, a computed for the accounting format, and the host bindings that paint the text, the negative state, the colour, and the spoken label — formatting and presentation in the one place a pipe can't reach.

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

/*
 * The one place USD money is formatted in this monorepo. Both the `[fooAmount]` directive and
 * the data grids (which render plain string cells and so can't host a directive) call this, so
 * a balance reads identically whether a directive painted it or a grid stringified it. One
 * formatter, no second convention to keep in sync.
 */
const usdFormatter = (decimals: number) =>
  new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: decimals,
    maximumFractionDigits: decimals,
  });

/*
 * Accounting style: positives read as plain currency, negatives go in parentheses with no
 * minus sign — the fintech convention a finance reader expects, where a stray "-" is easy to
 * miss but "($234.00)" never is. We format the magnitude and add the parentheses ourselves
 * rather than lean on `currencySign: 'accounting'`, because engine support for that option is
 * uneven and a money figure is the wrong place to gamble on a polyfill.
 */
export function formatAccountingUsd(value: number, decimals = 2): string {
  const text = usdFormatter(decimals).format(Math.abs(value));
  return value < 0 ? `(${text})` : text;
}

/*
 * `[fooAmount]` renders a number as accounting-style USD straight into its host element. It's a
 * directive and not a pipe on purpose: a pipe could format the text, but it can't also colour a
 * debit, flag the host with a state class, or speak the sign to a screen reader. Formatting and
 * presentation belong together here, and the only seam that owns both at once is a host-bound
 * directive.
 *
 *   <span [fooAmount]="account.balance"></span>   ->  $1,234.56
 *   <span [fooAmount]="-234"></span>               ->  ($234.00), .is-negative, danger colour
 *
 * The value is written via `[textContent]`, so the host needs no projected content — point the
 * directive at an empty element and it fills it. Negatives also get an `.is-negative` class and
 * a danger colour, and the `aria-label` always carries the sign in words ("credit"/"debit") so a
 * non-sighted user hears what the parentheses convey visually.
 */
@Directive({
  selector: '[fooAmount]',
  host: {
    '[textContent]': 'formatted()',
    '[class.is-negative]': 'isNegative()',
    '[style.color]': 'isNegative() ? "var(--foo-color-danger-text)" : null',
    '[attr.aria-label]': 'label()',
  },
})
export class Amount {
  // The numeric value to render. Bound as the attribute itself: `[fooAmount]="balance"`.
  readonly value = input.required<number>({ alias: 'fooAmount' });

  // Fraction digits; 2 matches how money is written, but a whole-dollar KPI can pass 0.
  readonly decimals = input(2);

  protected readonly isNegative = computed(() => this.value() < 0);

  protected readonly formatted = computed(() =>
    formatAccountingUsd(this.value(), this.decimals()),
  );

  /*
   * The screen-reader label says the plain magnitude and the direction of the money, not the
   * literal "($234.00)" — punctuation that a screen reader would read as "open paren" tells a
   * listener nothing useful. "234 dollars debit" does.
   */
  protected readonly label = computed(() => {
    const value = this.value();
    const magnitude = Math.abs(value).toLocaleString('en-US', {
      minimumFractionDigits: this.decimals(),
      maximumFractionDigits: this.decimals(),
    });
    return `${magnitude} dollars ${value < 0 ? 'debit' : 'credit'}`;
  });
}

Content projection

<ng-content> lets a component render markup its parent passes in — the difference between a card that is your content and one that only wraps a fixed layout. select= gives multiple named slots; the card below projects a header, a body, and a footer into three.

Projected header

This paragraph is the default-slot body — projected straight through from this page into the card's <ng-content>.

The design-system components (card, dialog, alert) all project — it's how one component serves countless layouts.

Dependency injection

Past inject(SomeClass) lie the pieces that make DI a system: an injection token is a typed key for things that aren't classes (config, primitives), and a multi-provider token collects contributions from across the app into one array — the mechanism behind HTTP_INTERCEPTORS and NG_VALIDATORS.

Feature flags (a typed token)
betaCharts is on, experimentalExport is off — read from inject(FEATURE_FLAGS), whose default ships with the token.

2 rules registered via the multi-token. Value is required.

View source — injection token + multi-providerfeature-flags.ts

The token carries its own type and a root factory, so inject(FEATURE_FLAGS) is typed and needs zero wiring. The multi-token returns the array of every rule provided for it — add a rule by providing it, never by editing the consumer.

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

/*
 * DI beyond `inject(SomeClass)`. A class is its own token, but plenty of things you want to
 * inject aren't classes — config objects, primitives, a list of plugins. An `InjectionToken`
 * is a typed key for exactly those: it carries the type, so `inject(FEATURE_FLAGS)` comes back
 * as `FeatureFlags`, not `any`, and it can't collide with another token of the same shape.
 *
 * The `factory` is the default: if nobody provides this token, DI calls the factory in the
 * root injector. That makes the token usable with zero wiring (a sane default ships for free),
 * while any injector up the tree can still override it with its own `provide`. The hierarchy is
 * the point — a route or component can narrow a flag for its subtree without touching the root.
 */
export interface FeatureFlags {
  readonly betaCharts: boolean;
  readonly experimentalExport: boolean;
}

export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>('FEATURE_FLAGS', {
  // `providedIn: 'root'` + factory = a tree-shakable default. No NgModule, no provider array.
  providedIn: 'root',
  factory: () => ({ betaCharts: true, experimentalExport: false }),
});

/*
 * A multi-provider token. With `multi: true`, every `provide` for this token contributes one
 * entry to an array instead of overwriting the last — so independent parts of the app can each
 * register a validation rule (or interceptor, or initializer) without knowing about the others.
 * `inject(VALIDATION_RULES)` returns all of them. This is the exact mechanism behind
 * HTTP_INTERCEPTORS, NG_VALIDATORS, and APP_INITIALIZER.
 */
export interface ValidationRule {
  readonly name: string;
  validate(value: string): string | null;
}

export const VALIDATION_RULES = new InjectionToken<readonly ValidationRule[]>('VALIDATION_RULES');

// A couple of concrete rules a feature could register independently into the multi-token.
export const nonEmptyRule: ValidationRule = {
  name: 'non-empty',
  validate: (value) => (value.trim().length === 0 ? 'Value is required.' : null),
};

export const maxLengthRule: ValidationRule = {
  name: 'max-length',
  validate: (value) => (value.length > 20 ? 'Keep it under 20 characters.' : null),
};

/*
 * A tiny consumer that reads the multi-token and runs every registered rule. It has no idea
 * which rules exist or where they came from — it just asks the injector for the array. Add a
 * rule by providing it; this code never changes.
 */
export function runValidationRules(value: string, rules: readonly ValidationRule[]): string[] {
  return rules.map((rule) => rule.validate(value)).filter((msg): msg is string => msg !== null);
}

// Convenience for the demo component: pull both tokens through field initializers.
export function injectFeatureFlags(): FeatureFlags {
  return inject(FEATURE_FLAGS);
}

Services

A service is a plain class that owns one job and is shared through DI — the place state and the work over it live, so components stay about the view. @Injectable({ providedIn: 'root' }) makes it a tree-shakable singleton: one shared instance, and if nothing injects it the whole class drops from the bundle — which a provider in a module array can't do. Reach for a component- or route-level provider only when you want a fresh, scoped instance per subtree instead of one for the app.

Inside, inject() pulls dependencies — it reads cleaner than a constructor and works in a field initializer, where constructor injection can't. (Constructor injection still reads well when a base class or a clear ordered list of deps is the point.) The discipline that makes a service safe to share is single responsibility: one service, one job. A watchlist tracks watched ids; it doesn't also fetch, persist, and route.

  • Aurora Holdings
  • Borealis Fund
  • Cygnus Capital

Watching 0 — the count is a computed off the service's signal, so it tracks every toggle with no wiring in this component.

Exposing state without leaking it
The service keeps a private writable signal and exposes only a public asReadonly() view plus computed derivations (count, isFull). Consumers read and react; they can't reach in and mutate — every change goes through a named method (add/remove/toggle), so the legal transitions stay in one place. When the data is a stream rather than a value, the same instinct applies: expose an Observable (and keep the Subject private) so consumers subscribe but can't next() into it.

Services are also where data fetching lives — a component asks the service, the service owns the call. The HttpClient & interceptors example below is exactly that shape: QuotesService wraps a typed HttpClient.get and the component never touches HTTP directly.

The same shape runs in production across this app: ThemeService and ToastService in @foo/ui each own one slice of state behind a read-only signal, and the cross-boundary ReviewQueueStore in @foo/state-shared is the federation-spanning version of the same idea. The state pages show where a service grows into a store.

View source — a single-purpose root servicewatchlist.service.ts

providedIn: 'root' for a tree-shakable singleton; inject() for its one dependency; a private writable signal exposed only as an asReadonly() view plus computed derivations, so the component can read the count but can't mutate the set. Every transition is a named method.

ts
import { Injectable, computed, inject, signal } from '@angular/core';
import { FEATURE_FLAGS } from './feature-flags';

/*
 * A single-purpose service: it tracks which account ids are "watched" and nothing else. That
 * narrow remit is the point — one service, one job — so it stays easy to reason about, test, and
 * reuse. Anything else a watchlist might eventually want (persistence, a fetch) belongs in its own
 * service this one would depend on, not bolted on here.
 *
 * `providedIn: 'root'` makes it a tree-shakable singleton: a single shared instance for the whole
 * app, and — because the provider lives on the class, not in a module array — it drops out of the
 * bundle entirely if nothing injects it. Use a component/route provider instead only when you
 * genuinely want a fresh, scoped instance per subtree.
 *
 * State is exposed as a read model, never as the writable signal. `_watched` is private; consumers
 * get `ids` (an `asReadonly()` view) and the `count` derivation, so they can read and react but
 * can't reach in and mutate. Every change goes through a named method, which keeps the set of legal
 * transitions in one place.
 */
@Injectable({ providedIn: 'root' })
export class WatchlistService {
  // A real dependency, pulled with inject() — reads cleaner than a constructor parameter and works
  // the same in a field initializer. The flag caps the list so the demo can show a "full" state.
  private readonly flags = inject(FEATURE_FLAGS);
  private readonly limit = this.flags.experimentalExport ? 50 : 8;

  private readonly _watched = signal<ReadonlySet<string>>(new Set());

  // The public read model. Components bind to this; it can't be reassigned from outside.
  readonly ids = this._watched.asReadonly();

  // A derived value, recomputed only when the set changes — consumers never count by hand.
  readonly count = computed(() => this._watched().size);

  // True once the list hits its cap; the UI uses it to disable further adds.
  readonly isFull = computed(() => this._watched().size >= this.limit);

  has(id: string): boolean {
    return this._watched().has(id);
  }

  add(id: string): void {
    if (this.has(id) || this.isFull()) {
      return;
    }
    this._watched.update((set) => new Set(set).add(id));
  }

  remove(id: string): void {
    this._watched.update((set) => {
      const next = new Set(set);
      next.delete(id);
      return next;
    });
  }

  toggle(id: string): void {
    if (this.has(id)) {
      this.remove(id);
    } else {
      this.add(id);
    }
  }
}

Routing fundamentals

Routes map paths to lazily-loaded components; routerLink navigates without a full reload and routerLinkActive styles the current one; fragment and query params carry state in the URL; child routes nest. Every link on this page and the whole sidebar is this — the deep links here use fragment to land on a section.

Route guards

Guards answer three questions at three moments: CanMatch ("should this route even be considered?" — false falls through to another route), CanActivate ("may we enter?" — the auth gate, redirects with a UrlTree), and CanDeactivate ("may we leave?" — the unsaved-changes prompt). Flip the demo session and try the links:

signed out role: guest

Signed out, the secure link redirects back here. As a guest the admin link doesn't match (the router falls through). In the draft editor, type then try to leave.

View source — three functional guardsguards.ts

Functional guards are just functions that run in the injection context, so inject() works directly — no guard classes. CanActivate returns a UrlTree to redirect; CanMatch skips the route (and its lazy chunk) entirely; CanDeactivate calls a method the component owns.

ts
import { Injectable, inject, signal } from '@angular/core';
import {
  CanActivateFn,
  CanDeactivateFn,
  CanMatchFn,
  Router,
} from '@angular/router';

/*
 * Functional guards — the modern shape. A guard is just a function that returns a boolean or a
 * UrlTree (redirect), and it runs inside the injection context, so `inject()` works directly.
 * No guard classes, no `implements CanActivate`. The three below cover the three questions the
 * router asks at three different moments:
 *
 *   CanMatch    — "should this route even be considered?" (runs before the route is matched, so
 *                 a false here can fall through to a different route — the basis of role-based
 *                 routing and feature-flagged routes, and it skips lazy-loading the chunk).
 *   CanActivate — "now that we've matched, may we enter?" (the classic auth gate; redirects).
 *   CanDeactivate — "may we leave?" (the unsaved-changes prompt; the guard the user feels most).
 */

/*
 * A trivially small "session" so the guards have something real to read. A signal, not a class
 * field, so the demo page can flip it and watch the guards react. In a real app this is an auth
 * service; the guard shape is identical.
 */
@Injectable({ providedIn: 'root' })
export class DemoSession {
  readonly signedIn = signal(false);
  readonly role = signal<'guest' | 'admin'>('guest');

  toggleSignedIn(): void {
    this.signedIn.update((v) => !v);
  }

  toggleRole(): void {
    this.role.update((r) => (r === 'admin' ? 'guest' : 'admin'));
  }
}

/*
 * CanActivate: gate entry on being signed in. Returning a UrlTree redirects instead of just
 * blocking — here, back to the fundamentals hub's guards section, so the user lands somewhere
 * sensible rather than on a dead navigation.
 */
export const requireSignIn: CanActivateFn = () => {
  const session = inject(DemoSession);
  const router = inject(Router);
  return session.signedIn() ? true : router.parseUrl('/fundamentals');
};

/*
 * CanMatch: only match the admin route when the role is admin. Because this runs at match time,
 * a non-admin doesn't get redirected — the route simply isn't there for them, and the router
 * moves on. `route` and `segments` are available if the decision depends on the path.
 */
export const requireAdmin: CanMatchFn = () => {
  const session = inject(DemoSession);
  return session.role() === 'admin';
};

/*
 * The contract a guarded component opts into. CanDeactivate is generic over the component, so
 * the guard can call a method the component owns. Keeping it an interface (not reaching into
 * component internals) means the guard stays decoupled — any component that can answer "is it
 * safe to leave?" can be guarded by it.
 */
export interface CanLeave {
  canLeave(): boolean;
}

/*
 * CanDeactivate: ask the component before navigating away. If it has unsaved work, confirm.
 * The `confirm()` is the demo stand-in for a real dialog; the guard's job is only to gate the
 * navigation on the answer, not to own the UI.
 */
export const confirmLeave: CanDeactivateFn<CanLeave> = (component) => {
  if (component.canLeave()) {
    return true;
  }
  return confirm('You have unsaved changes. Leave anyway?');
};

Resolvers

A resolver preloads a route's data before it activates, so the component renders with its data already in hand — no in-component spinner, no flash of empty shell. The trade-off: it delays the navigation until the data arrives, which is right for small must-have data and wrong for a slow call.

Open the resolved briefing →
View source — a functional resolverbriefing.resolver.ts

The router waits on the returned Observable to complete and hands the value to the component via the route snapshot — which is why the destination component has no loading branch to write.

ts
import { ResolveFn } from '@angular/router';
import { Observable, delay, of } from 'rxjs';

/*
 * A resolver preloads a route's data before the route activates, so the component renders with
 * its data already in hand — no in-component loading spinner, no flash of an empty shell. The
 * router waits on whatever the resolver returns (a value, a Promise, or an Observable that
 * completes) and hands the result to the activated component via the route snapshot.
 *
 * The trade-off worth naming: a resolver delays the navigation until the data arrives. That's
 * the right call for small, fast, must-have data (this briefing), and the wrong call for a slow
 * call that would leave the user staring at the old page — there, render and stream instead.
 */
export interface Briefing {
  readonly title: string;
  readonly generatedAt: string;
  readonly summary: string;
}

/*
 * The synthetic "fetch". `of(...).pipe(delay)` stands in for an HTTP call so the resolver has
 * something asynchronous to await — the point is the timing contract, not the data source.
 */
export function loadBriefing(): Observable<Briefing> {
  return of<Briefing>({
    title: 'Daily desk briefing',
    generatedAt: new Date().toISOString(),
    summary: 'Resolved before the route activated — the component never rendered without it.',
  }).pipe(delay(300));
}

/*
 * Functional resolver: runs in the injection context, so a real one would `inject()` an HTTP
 * service here. The component reads the result with `inject(ActivatedRoute).snapshot.data`.
 */
export const briefingResolver: ResolveFn<Briefing> = () => loadBriefing();

HttpClient & interceptors

A typed HttpClient.get<Quote[]> through a chain of functional interceptors. The app's data is synthetic, so a fake-backend interceptor plays the server — the same technique you'd use against an API that doesn't exist yet — while auth, retry, and error-mapping interceptors run against a real request/response cycle. "Load (with failures)" arms two transient 503s so the retry interceptor has something to recover from.

View source — interceptor chain, fake backend, typed servicehttp-demo.ts

Each interceptor is middleware wrapping next — an onion: auth stamps the header on the way out, retry sits inside it, the fake backend is the core that responds. The service knows nothing about any of it; interceptors are wired once at the provider level.

ts
import { Injectable, inject } from '@angular/core';
import {
  HttpBackend,
  HttpClient,
  HttpErrorResponse,
  HttpEvent,
  HttpHandlerFn,
  HttpInterceptorFn,
  HttpRequest,
  HttpResponse,
} from '@angular/common/http';
import { Observable, defer, of, throwError } from 'rxjs';
import { catchError, delay, retry } from 'rxjs/operators';

/*
 * HttpClient fundamentals with a chain of functional interceptors over a fake backend. The app's
 * data is synthetic, so there's no server — instead a class implementing `HttpBackend` plays the
 * server at the very bottom of the stack. That placement matters: the backend is the request's
 * single producer, below every interceptor, so when the retry interceptor resubscribes it
 * genuinely re-runs the backend — exactly as a resubscribe would re-hit a real server. (An
 * interceptor-as-backend wouldn't: the chain is built once per request and doesn't re-execute on
 * a downstream retry.) Same technique you'd use to develop against an API that doesn't exist yet.
 *
 * The mental model that makes interceptors click: each one is middleware wrapping `next`. It can
 * touch the outgoing request before calling `next(req)`, and touch the incoming stream after. The
 * order you register them is the order requests pass through going out, and the reverse coming
 * back — an onion. Auth (outermost) stamps the header first; retry sits inside it; the backend is
 * the core that finally responds.
 */

export interface Quote {
  readonly symbol: string;
  readonly price: number;
  readonly currency: string;
}

// A typed, immutable book of canned quotes the fake backend serves.
const QUOTES: readonly Quote[] = [
  { symbol: 'ACME', price: 184.22, currency: 'USD' },
  { symbol: 'GLOBEX', price: 57.9, currency: 'USD' },
  { symbol: 'INITECH', price: 312.45, currency: 'USD' },
];

export const QUOTES_URL = '/api/quotes';

/*
 * Outermost interceptor: stamp an Authorization header on every outgoing request. Requests are
 * immutable, so you `clone` to change them — mutating `req` in place would be a no-op and a
 * source of confusing bugs. A real one reads a token from an auth service via `inject()`.
 */
export const authInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> => {
  const authed = req.clone({ setHeaders: { Authorization: 'Bearer synthetic-demo-token' } });
  return next(authed);
};

/*
 * Retry transient failures with backoff, then map anything still failing into a clean, typed
 * Error so components never have to know about `HttpErrorResponse`. Splitting "retry" from "map"
 * keeps each concern legible: retry decides whether to try again, the catch decides what the rest
 * of the app sees on final failure.
 */
export const retryAndMapErrorsInterceptor: HttpInterceptorFn = (
  req: HttpRequest<unknown>,
  next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> =>
  next(req).pipe(
    retry({ count: 2, delay: 200 }),
    catchError((err: HttpErrorResponse) =>
      throwError(() => new Error(`Request to ${req.url} failed (${err.status}).`)),
    ),
  );

/*
 * The fake backend: a real `HttpBackend`, the request's single producer. It answers QUOTES_URL,
 * models latency with `delay`, and — armed by a `?fail=true` query param — fails a couple of
 * times with a 503 before succeeding, so the retry interceptor above has something to recover
 * from. `defer` is the key to making retry work: it re-reads the mutable failure counter on every
 * (re)subscription, so each retry attempt sees the decremented state rather than a value captured
 * once at request time.
 */
@Injectable()
export class FakeBackend implements HttpBackend {
  private failuresRemaining = 0;
  // A one-shot latch so a fail-run arms its failures once, not afresh on every retry attempt.
  private failRunActive = false;

  handle(req: HttpRequest<unknown>): Observable<HttpEvent<unknown>> {
    return defer(() => {
      if (req.params.get('fail') === 'true' && !this.failRunActive) {
        this.failuresRemaining = 2;
        this.failRunActive = true;
      }

      if (this.failuresRemaining > 0) {
        this.failuresRemaining--;
        return throwError(
          () =>
            new HttpErrorResponse({ status: 503, statusText: 'Service Unavailable', url: req.url }),
        ).pipe(delay(150));
      }

      // Reached a success — drop the latch so the next fail-run can arm afresh.
      this.failRunActive = false;
      return of(new HttpResponse<Quote[]>({ status: 200, body: [...QUOTES] })).pipe(delay(250));
    });
  }

  // Test-only: arm a fixed number of upcoming 503s to exhaust the retry budget on purpose.
  armFailures(count: number): void {
    this.failuresRemaining = count;
    this.failRunActive = true;
  }

  // Test-only reset between cases.
  reset(): void {
    this.failuresRemaining = 0;
    this.failRunActive = false;
  }
}

/*
 * A typed data service over HttpClient. The generic on `get<Quote[]>` is the contract: the
 * response body is typed all the way to the caller, so components consume `Quote[]`, not `any`.
 * The service knows nothing about interceptors or the backend — they're wired once at the
 * provider level and apply to every request this makes.
 */
@Injectable({ providedIn: 'root' })
export class QuotesService {
  private readonly http = inject(HttpClient);

  load(options: { fail?: boolean } = {}): Observable<Quote[]> {
    return this.http.get<Quote[]>(QUOTES_URL, {
      params: options.fail ? { fail: 'true' } : {},
    });
  }
}

Lifecycle hooks

ngOnInit runs once inputs are set; ngOnChanges on every input change; ngOnDestroy on teardown. For cleanup, the modern path is DestroyRef + takeUntilDestroyed() — it ties a subscription's life to the component's without a hand-written ngOnDestroy, which is exactly how the RxJS page keeps its streams from leaking.

See takeUntilDestroyed in the RxJS demos →