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.
- Signals —
signal/computed/linkedSignalin the deferrable-views demo. - Control flow —
@if/@for/@switchthroughout, 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.
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)
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.
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.
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.00View 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.
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.
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.
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.
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 computed off the service's signal, so it tracks every toggle with no wiring in this component.
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.
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, 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.
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.
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.
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.