Angular Global Error Handling
What actually reaches Angular's ErrorHandler, how to write one that never throws or loops, and where HTTP and chunk-load errors belong.
Components, dependency injection, change detection, signals and the RxJS operators that come up in every Angular round.
Official reference: angular.dev
What actually reaches Angular's ErrorHandler, how to write one that never throws or loops, and where HTTP and chunk-load errors belong.
How resource() turns a signal-driven request into value, status and error signals, cancels stale loads, and why value() can throw on error.
Without zone.js, Angular renders only when notified. See which updates notify, which silently don't, and how TestBed catches the gaps for you.
Fix Angular NG8002 Can't bind to 'ngModel' since it isn't a known property of 'input': import FormsModule, then fix formControl and inputs.
Fix 'blocked by CORS policy' errors when Angular ng serve calls a backend: proxy.conf.json, angular.json wiring and production CORS setup.
Fix Angular NG0100 ExpressionChangedAfterItHasBeenCheckedError: why the dev-mode check fires, and data-flow fixes that beat setTimeout.
Fix Angular NG8001 'app-x' is not a known element: standalone imports, selector typos, NgModule exports, schemas and TestBed setup.
Fix Angular NullInjectorError: No provider for X! (NG0201): read the injector path, add provideHttpClient(), providedIn root or a token.
A username-availability check traced step by step: PENDING status, cancelled checks, debouncing, errors that hang the form, and safe submit.
Test an Angular component through its DOM, inputs, outputs and HTTP calls, so refactors pass and real bugs fail. Five tests, run and broken.
Why projected content is created by the parent even inside a closed @if, how select slots match, and when ng-template makes projection lazy.
A star-rating ControlValueAccessor, built and then broken: what writeValue, onChange, onTouched and setDisabledState each must do.
Where a service is provided decides who shares it: providedIn root, component providers, viewProviders and route providers, with instance ids.
How one innocent import moved a lazy feature into the initial bundle (233 kB to 407 kB), and how to draw boundaries that keep lazy routes lazy.
Why an OnPush child ignores user.role = 'admin' but updates for a new object, what else marks it dirty, and how signal inputs change the picture.
Who owns the edit state in an Angular reactive form: loading server data with patchValue, splitting subforms, avoiding echo loops and lost edits.
Route guards decide what the Angular router shows, not what a user may do. Why the admin chunk is still public and where the real check belongs.
Going from /products/1 to /products/2 reuses the component, so snapshot reads go stale. Read params with paramMap, toSignal or input binding.
What a ResolveFn really does to navigation: the delay, reruns on param change, RedirectCommand for 404s, and why EMPTY or an error cancels it.
switchMap, mergeMap, concatMap or exhaustMap? One timeline of four requests shows what each operator does, and which fits search, save and login.
Why a cart total belongs in computed() and not a second signal, how Angular tracks dependencies lazily, and when linkedSignal fits.
Why push() on a signal's array leaves the screen stale, what Object.is equality means for update(), and how to edit nested lists without bugs.
How a standalone component's imports array works, the NG8001, NG8002 and NG8004 errors it prevents, and the directive that fails silently.
Which Angular subscriptions leak and which end on their own, measured with a live Subject, plus takeUntilDestroyed, toSignal and the async pipe.
I'd start with the inputs. Options arrive through options = input.required<readonly Option[]>(), each with a stable id and a separate label, so display text can change or be translated without breaking stored values. The selection is value = model<string | null>(null), which gives the parent [(value)]="countryId". For reactive forms I'd also implement ControlValueAccessor, or build on the CDK's cdkListbox, which already handles arrow keys, typeahead and forms.
Compare options by id, never by object reference. After a refetch the server returns new objects, and a === check silently drops the selection; that's why mat-select has compareWith.
It also needs disabled options, an empty state, and role="listbox" with aria-selected. And it must never sort or splice the options array in place, because that array belongs to the parent. Derive a sorted copy with computed() and send changes out through the model.
input.required() for options, model() for the valueid kept separate from the display labelcompareWith does, not by referenceoptions arrayLikely follow-up: When would you implement ControlValueAccessor instead of relying on a model() input? · How would you add multiple selection without breaking the single-select API?
I'd split it along what changes together. Take an order details page. The page component stays as the container: it injects the store, reads the route id, loads data and handles save and cancel. Under it sit presentational components such as OrderSummary, LineItemsTable and ShippingForm. They get data through input() and report user actions through output(), for example (quantityChange)="store.updateQuantity($event)", and they never call services themselves, which makes each one easy to test with plain inputs.
Each standalone component lists only what its own template uses in imports, such as CurrencyPipe or RouterLink. I wouldn't add a SharedModule that exports forty things, because then nobody can tell what a component actually depends on.
Two more tools help: @defer (on viewport) for heavy sections lower on the page, and child routes when the screen is really several tabs. The mistake to avoid is splitting by line count, which produces components that need ten inputs and still share hidden state.
input() and output()SharedModule@defer or child routes for heavy or tabbed sectionsLikely follow-up: How would you share one reactive form between the container and a child component?
@for loop lose what the user typed whenever the list refreshes from the server. What would you check?midFirst, the track expression. track row keys each row by object reference. A refresh brings new objects, so every key looks new: Angular destroys each app-row-editor and creates a fresh one, and anything held inside it, such as a half-typed value, focus or an open dropdown, is gone. The fix is track row.id, a stable key from the data. track $index is wrong too: it keeps rows by position, so after a sort or a deletion the typed value stays in row 3 while a different record moves into row 3.
Second, where the edits live. Unsaved edits shouldn't exist only in a component that can be destroyed. I'd keep them in a FormArray or a Map keyed by id, and merge fresh data without overwriting rows the user has touched.
Then I'd test it: start typing in a row, trigger a refresh, insert a row above it, sort, and check the value stays with the right record.
@for (row of rows(); track row) {
<app-row-editor [row]="row" />
}track row keys by reference, so a refresh recreates rowstrack row.id, not $indexLikely follow-up: If the track key matches but the object reference changed, what does Angular do with that row?
When it encodes a business rule, repeats, or does real work. A condition like @if (user().roles.includes('admin') && !user().suspended && order().total > 1000) is a refund policy hiding in markup. You can't unit test it without rendering, and the next developer copies it into another template. I'd name it in the class, canRefund = computed(() => ...), and write @if (canRefund()).
Performance is the other reason. A method call such as {{ formatTotal(order) }} runs on every change detection pass that checks the component, which can be many times per second with the default strategy. A computed() signal or a pure pipe recalculates only when its inputs change. Calling a signal like total() in a template is fine, though: it's a cheap read of a cached value, not a recalculation.
Simple bindings like [disabled]="saving()" stay in the template. For repetition that isn't logic, @let (stable since Angular 19) gives a local alias, for example @let address = order().shipping.address;.
computed() signalscomputed() and pure pipes cache resultstotal() is cheap@let for local aliases, not logicLikely follow-up: Why is calling total() in a template different from calling getTotal()?
contains(event.target)?midThe basic version listens on the document and emits when host.contains(event.target) is false. Real apps add several traps:
mat-menu opened from inside the panel renders under body, so a click in that menu looks like an outside click. Accept extra elements that count as inside, or use the CDK's overlayOutsideClick output when the panel is itself a connected overlay.pointerdown instead of click avoids it, and also stops a text selection that ends outside from closing anything.event.target is retargeted to the shadow host, so check event.composedPath().'(document:pointerdown)' is removed automatically; a manual addEventListener needs DestroyRef.onDestroy.I'd test nested popovers and keyboard dismissal, not just one mouse click.
body; allow extra inside elementspointerdown so the opening click doesn't close itcomposedPath() for Shadow DOMDestroyRefLikely follow-up: In a zone.js app, how would you stop a document-level listener from triggering change detection on every click?
computed() signal for derived display data?midI ask whether the transformation is generic formatting or part of one component's state.
A pure pipe suits reusable formatting that doesn't belong to any one component: {{ amount | money: currency }} or {{ createdAt | relativeTime }}. It reruns only when its input or an argument changes, by reference for objects, and any template can use it.
A computed() suits derived state, such as visibleTodos = computed(() => this.todos().filter(t => this.filter() === 'all' || t.status === this.filter())). It's memoized, several template bindings can read it, and the class can build on it too, for example canClear = computed(() => this.visibleTodos().length > 0). A pipe is awkward to reuse from TypeScript.
Both rely on immutable updates. A pure pipe won't rerun if you push into the same array, and a signal won't notify anyone if update() returns the same mutated array, because signals compare values with Object.is. Neither tool fixes mutation.
computed() for component or store statecomputed(); pipes are awkward thereObject.isLikely follow-up: When would you mark a pipe pure: false, and what does it cost?
ExpressionChangedAfterItHasBeenCheckedError (NG0100) in development?hardI'd start with the message. It names the expression and both values, such as Previous value for 'title': 'Loading'. Current value: 'Loaded'. The error exists only in development: after each change detection pass, Angular runs a second, read-only checkNoChanges pass and throws if a binding moved in between. Production skips that pass, so the bug is still there, just unreported.
The usual causes: most often a child writes state the parent already rendered, such as a child's ngAfterViewInit calling layout.setTitle(). Next comes an expression that differs on every read, like Date.now() in a getter, and then feedback loops between parent and child bindings.
The fix is moving where the value is produced: compute it in the parent, set it earlier in the constructor or ngOnInit, or hold it in a signal, because a signal write marks the reading view for another refresh in the same tick. Wrapping the write in setTimeout or calling detectChanges() only hides it, and zoneless, the value may never render.
checkNoChanges pass compares every bindingngAfterViewInit writing parent state is the usual causeDate.now()setTimeout and detectChanges() only hide itLikely follow-up: Why can an OnPush tree hide this error, and how would you find it anyway?
@if block?midThe element doesn't exist until the @if turns true, so @ViewChild read in ngAfterViewInit is the wrong tool: that hook runs once, possibly before the panel appears. A signal query, panel = viewChild<ElementRef<HTMLElement>>('panel'), is undefined while the block is hidden and holds the element once it renders.
I read layout in afterRenderEffect. It runs after Angular has updated the DOM, only in the browser, and again whenever panel() changes. That covers server rendering too: render hooks never run on the server, where there's no layout to measure, so no isPlatformBrowser checks are needed.
If the size can change later, I attach a ResizeObserver and disconnect it in onCleanup, which runs when the element goes away and when the component is destroyed. If I also write styles, the earlyRead and write phases keep all reads before writes, which avoids forced synchronous layout. What I avoid is setTimeout guesses about when the element exists.
panel = viewChild<ElementRef<HTMLElement>>('panel');
panelHeight = signal(0);
constructor() {
afterRenderEffect((onCleanup) => {
const el = this.panel()?.nativeElement;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
this.panelHeight.set(entry.contentRect.height),
);
observer.observe(el);
onCleanup(() => observer.disconnect());
});
}viewChild() is undefined until the @if rendersafterRenderEffect or afterNextRenderResizeObserver in onCleanupLikely follow-up: What's the difference between afterNextRender and afterRenderEffect?
Where the service is provided, because every injector that lists a provider creates its own instance. The usual culprit is providers: [CartService] in a component decorator. Each instance of that component gets a new CartService, which shadows the root singleton for that component and its children, even though the class says providedIn: 'root'. Two other common causes are a lazy route's providers array, which creates a separate instance for everything under that route, and importProvidersFrom(SomeModule) providing it again.
To confirm, I'd open the injector tree in Angular DevTools, or log an instance id in the constructor and see two values.
Then decide what the state should be. A cart shared across the page belongs at root, so I'd delete the component provider. Per-widget state, like an unsaved draft in each editor, is a good reason to keep component providers, and Angular destroys that instance with the component. A test that renders both widgets should check that an update in one shows in the other.
providers shadow the root singletonLikely follow-up: What's the difference between providers and viewProviders on a component?
Usually through TestBed: TestBed.configureTestingModule({ imports: [OrderPage], providers: [{ provide: OrderApi, useValue: fakeApi }] }). The fake has the same public methods and returns controlled data, such as of(order) for success or throwError(() => new HttpErrorResponse({ status: 500 })) for the failure path. If the real service exposes signals, the fake should use real signal() values so the template reacts.
The trap is a component that declares its own provider, @Component({ providers: [OrderStore] }). That provider sits on the component's element injector, which is closer than TestBed's, so your fake is ignored and the real store runs. Replace it with TestBed.overrideComponent(OrderPage, { set: { providers: [{ provide: OrderStore, useValue: fakeStore }] } }) or TestBed.overrideProvider(OrderStore, { useValue: fakeStore }).
Then assert on what the user sees: the error message renders, and the retry button calls the fake again. If the service is a thin wrapper over HTTP, I often keep it real and fake the backend with HttpTestingController instead.
providers with useValue: fakeoverrideComponent or overrideProvider replaces themthrowError()Likely follow-up: When would you keep the real service and use HttpTestingController instead?
computed() replace an effect() that copies one signal into another, as in this snippet?midWhenever the second value is purely derived, as fullName is here. The writable signal and the effect both go, replaced by fullName = computed(() => this.first() + ' ' + this.last()).
The effect version has real costs. Effects run later, during change detection, so code that sets first and immediately reads fullName gets the old name. It's also a second writable source: anyone can call fullName.set() and the two drift apart. And it does extra work for nothing. Since Angular 19, writing signals inside effects is allowed by default, so nothing warns you; it's simply the wrong tool.
Effects are for side effects that leave the signal graph: saving to localStorage, logging, or driving a chart library. When a value is derived but the user can override it, such as a default shipping method that resets when the country changes, linkedSignal() fits better.
first = signal('Ada');
last = signal('Lovelace');
fullName = signal('');
constructor() {
effect(() => this.fullName.set(`${this.first()} ${this.last()}`));
}computed()localStorage or chartslinkedSignal() for derived values the user can overrideLikely follow-up: How does linkedSignal() differ from computed() plus a separate override signal?
I'd derive the request from the selection and let a resource handle the rest. With httpResource (added in 19.2, experimental in 19 and 20) the request function reads orderId() and returns a URL, or undefined when nothing is selected. Returning undefined keeps the resource idle, so there's no request to /api/orders/undefined.
When the id changes, the resource aborts the in-flight request and starts a new one, so a slow response for order 41 can't overwrite order 42. During that load value() drops to undefined and isLoading() is true; a manual reload(), by contrast, keeps the current value on screen while it refreshes. In v20, reading value() in the error state throws, so the template guards with hasValue() and offers a retry that calls reload().
Without resources, the same design is toObservable(orderId) piped through switchMap to HttpClient, then toSignal(). I'd test with HttpTestingController: select 41, then 42, check the first request was cancelled and only 42 renders.
@Component({
selector: 'app-order-details',
imports: [OrderCard, Spinner],
template: `
@if (order.isLoading()) { <app-spinner /> }
@if (order.error()) {
<p role="alert">Could not load the order.</p>
<button (click)="order.reload()">Retry</button>
}
@if (order.hasValue()) { <app-order-card [order]="order.value()" /> }
`,
})
export class OrderDetails {
orderId = input<string>();
order = httpResource<Order>(() => {
const id = this.orderId();
return id ? `/api/orders/${id}` : undefined;
});
}orderId() and returns a URLundefined means no selection, so nothing loadshasValue(); retry with reload()HttpTestingControllerLikely follow-up: How would you keep the previous order visible, dimmed, while the next one loads?
Without zone.js, Angular renders only when something notifies it: a signal read by a template changes, a template or host listener runs, markForCheck() is called (the async pipe does this), or ComponentRef.setInput() sets an input. So I look for state that changes without any of those. The classic case is a plain field assigned inside setTimeout, setInterval, a subscribe() callback, a .then(), or a third-party callback such as a map's click event. With zone.js those updated the screen. Zoneless, they silently don't.
Next, code waiting on NgZone.onStable or onMicrotaskEmpty, which never emit without zones; afterNextRender replaces it. With SSR, non-HTTP async work before rendering needs PendingTasks so the server waits for it.
The fastest audit is the test suite. Run it with provideZonelessChangeDetection() (provideExperimentalZonelessChangeDetection() in v19), prefer await fixture.whenStable(), and fix every NG0100 that TestBed throws for state changed without a notification. Components already on OnPush with signals usually pass unchanged.
markForCheck() or setInput()NgZone.onStable never emits; use afterNextRenderPendingTasks for non-HTTP async work under SSRLikely follow-up: Why are OnPush components that use signals usually zoneless-ready already?
I'd record the slow interaction with the Angular DevTools profiler. It shows each change detection cycle, what triggered it and how long each component took. Two patterns come up most.
Too many cycles: a setInterval, a mousemove or scroll listener, or a third-party library running timers inside the zone, each triggering a check of the whole tree dozens of times a second. The fix is running that code outside Angular with NgZone.runOutsideAngular(), or going zoneless, and notifying Angular only when something visible changes.
Expensive cycles: the default strategy checking a large tree, or templates calling methods like filteredRows() that sort thousands of items on every pass. Here I'd move work into computed() signals or pure pipes, add track row.id to big loops, and switch components to OnPush, after checking they don't mutate objects in place, because OnPush won't notice those changes.
Then I'd record the same interaction again and compare, rather than assuming the change helped.
computed(), pure pipes, track by id, then OnPushLikely follow-up: What exactly marks an OnPush component for checking?
A Promise is one value, delivered once, and its work starts as soon as you create it. An Observable can deliver many values over time, does nothing until you subscribe, and can be torn down by unsubscribing.
So Observables fit anything ongoing: form valueChanges, router events, WebSocket messages, or a search box where debounceTime, distinctUntilChanged and switchMap cancel the previous request. That cancellation is real with HttpClient: unsubscribing aborts the underlying request, while a fetch() Promise needs an AbortController for the same thing. It only works when the source implements teardown, though; wrapping a Promise in from() doesn't make its work cancellable.
One gotcha: HttpClient Observables are cold, so two subscriptions send two requests.
For a single result and simple flow, a Promise with async/await is fine, and firstValueFrom() converts when you need one. In Angular 19 and 20 much one-off loading also moves to signals through resource() or httpResource.
switchMap and debounceTime for streamsHttpClient requestfirstValueFrom() for single resultsLikely follow-up: Two components subscribe to the same http.get() Observable. What happens, and how would you share the result?
Start from the control's valueChanges, normalize with map(q => q.trim()), then debounceTime(300) and distinctUntilChanged() so pauses, not keystrokes, trigger requests and repeats are skipped.
switchMap does the important part: a new query unsubscribes from the previous request, which aborts it, so a slow old response can't overwrite newer results. Short queries still pass through it and map to an idle state instead of being dropped with filter; otherwise clearing the box leaves stale results while the last request lands.
Errors are caught inside the inner pipe. A catchError on the outer stream completes it, and search silently stops after the first failure.
Loading state comes from the same stream via startWith, so each request carries its own status. A shared loading flag set in a tap before switchMap and cleared in finalize breaks, because a cancelled request's finalize runs after the new query's tap and hides the spinner while the new request is still running. I'd test with fake timers and out-of-order responses.
readonly query = new FormControl('', { nonNullable: true });
readonly results = toSignal(
this.query.valueChanges.pipe(
map((q) => q.trim()),
debounceTime(300),
distinctUntilChanged(),
switchMap((q) =>
q.length < 2
? of({ status: 'idle' as const })
: this.api.search(q).pipe(
map((items) => ({ status: 'done' as const, items })),
catchError(() => of({ status: 'error' as const })),
startWith({ status: 'loading' as const }),
),
),
),
{ initialValue: { status: 'idle' as const } },
);trim, debounceTime, distinctUntilChangedswitchMap cancels the previous requestcatchError inside the inner pipestartWithLikely follow-up: How would you test the debounce and out-of-order responses deterministically?
concatMap, mergeMap, switchMap and exhaustMap for saving edits?hardI start from what the user expects to happen to each save.
concatMap queues saves and runs them in order. It's my default for edits to one record, such as autosaving field changes, because the server sees them in the order they were made.exhaustMap ignores new triggers while a save is running. It fits a Submit button: a double click sends one request.mergeMap runs saves in parallel. It suits independent writes, like saving several unrelated rows, but responses can arrive in any order.switchMap cancels the previous save when a new one starts. Great for reads, risky for writes: unsubscribing aborts the request in the browser, but the server may already have committed it, leaving a write you never confirmed.None of these undo anything on the server. If order really matters, send a version number so the server rejects stale writes with a 409, and an idempotency key so a retried request isn't applied twice.
concatMap keeps saves in orderexhaustMap ignores clicks while savingmergeMap runs in parallel with no orderingswitchMap cancels in the browser, not on the serverLikely follow-up: How would you autosave so only the latest state is sent, without reordering writes?
Two things here outlive the component: the updates$ subscription and the resize listener. Leaving the route destroys the component, but the root PriceFeed still holds a subscriber whose callback closes over it, so the callback keeps running and the component can't be garbage collected.
I'd list everything the component starts: subscriptions to long-lived streams, window or document listeners, setInterval, ResizeObserver, WebSocket handlers and third-party callbacks. HTTP calls complete on their own; most of the rest don't.
Then tie each one to the component's lifetime. takeUntilDestroyed() ends a subscription when called in the constructor or a field initializer, or when given a DestroyRef. toSignal() and the async pipe subscribe and unsubscribe for you. DestroyRef.onDestroy() removes manual listeners, and a host listener like (window:resize) is cleaned up automatically.
To confirm the fix, I'd take heap snapshots in Chrome DevTools after leaving the route a few times and search for LiveTicker. Retained instances show what's holding them.
export class LiveTicker implements OnInit {
private prices = inject(PriceFeed);
latest = signal<Price | null>(null);
ngOnInit() {
this.prices.updates$.subscribe((p) => this.latest.set(p));
window.addEventListener('resize', () => this.layout());
}
layout() {
// recompute column widths
}
}takeUntilDestroyed(), toSignal() or the async pipeDestroyRef.onDestroy() for manual listenersLikely follow-up: Why does takeUntilDestroyed() throw when it's called inside ngOnInit without arguments?
I keep RxJS where time and cancellation matter, and convert once, where the component consumes the result. A search stream keeps debounceTime and switchMap in RxJS, because signals have no notion of time or of cancelling a request. Its final value becomes results = toSignal(results$, { initialValue: [] }), and the template reads results() without an async pipe.
Two decisions come with the conversion. The initial value: without initialValue the type includes undefined, and requireSync: true works only for sources that emit synchronously, like a BehaviorSubject. And errors: if the Observable errors, reading the signal throws, so I catch errors upstream and turn them into a state value.
Mistakes I look for: calling toSignal() inside a method or a computed(), which throws outside an injection context or, given an injector, creates a new subscription on every call; and bouncing between toSignal() and toObservable(), which adds asynchronous hops and makes data flow hard to follow. One conversion per stream, in a field initializer, is the pattern.
debounceTime and switchMap in RxJStoSignal() where the component reads itinitialValue or requireSynctoSignal() in methods or computed()Likely follow-up: When would you go the other way with toObservable()?
Three things keep the request count down. First, sync validators go first: Angular runs async validators only when every sync validator passes, so required and a pattern check stop empty or malformed names from reaching the server.
Second, debounce inside the validator. On each value change Angular unsubscribes from the previous async validation, so starting with timer(400) before switchMap to the API means only a pause in typing sends a request, and stale checks are cancelled. updateOn: 'blur' is the simpler option if checking on blur is acceptable.
Third, the returned Observable must complete. HTTP does; a long-lived stream would leave the control PENDING forever.
While the check runs, the status is PENDING, so I show 'Checking' and disable Submit while form.pending is true. A failed check returns a distinct error such as checkFailed, not taken. And the server still enforces uniqueness on submit, because two people can claim the same name between check and save.
function usernameAvailable(api: UserApi): AsyncValidatorFn {
return (control) =>
timer(400).pipe(
switchMap(() => api.isAvailable(control.value)),
map((available) => (available ? null : { taken: true })),
catchError(() => of({ checkFailed: true })),
);
}
username = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.pattern(/^[a-z0-9_]{3,20}$/)],
asyncValidators: [usernameAvailable(inject(UserApi))],
});timer() then switchMap debounces inside the validatorPENDINGtaken from a failed checkLikely follow-up: Why doesn't adding debounceTime(400) to the validator's pipe debounce anything?
Say the API answers with a 422 and a body like { errors: [{ field: 'address.zip', message: 'Unknown postcode' }] }. For each error I look up the control with form.get(field), which accepts dot paths into nested groups, and call setErrors({ server: message }) on it. The template then shows it like any other validation error. Errors with no matching control, such as 'This order has already shipped', go into a form-level message instead of being dropped.
I don't reset the form; the user's values stay as they were. Clearing is mostly automatic, because errors set with setErrors are replaced the next time that control's validators run, which happens as soon as the user edits the field.
For accessibility, markAllAsTouched() makes errors render, each input gets aria-invalid and an aria-describedby pointing at its message, and focus moves to an error summary at the top or to the first invalid field. A toast alone is easy to miss. Other failures get a generic 'Couldn't save, try again' with the form intact.
form.get(field)?.setErrors({ server: message })markAllAsTouched(), aria-describedby and focus the summaryLikely follow-up: How would you show a server error that applies to two fields at once, such as an invalid date range?
I'd implement ControlValueAccessor and register the component with NG_VALUE_ACCESSOR and multi: true. The four methods each cover one direction:
writeValue(v): the form sets the value, on init, setValue() or reset(). Update the display only.registerOnChange(fn): store fn and call it when the user picks a star.registerOnTouched(fn): call it on blur, so touched and updateOn: 'blur' work.setDisabledState(isDisabled): make the stars inert when the control is disabled.The classic bug is calling onChange inside writeValue. A programmatic reset() then marks the form dirty and fires valueChanges as if the user had clicked.
For accessibility, the simplest approach is five native radio inputs styled as stars, inside a group with a label. That gives arrow-key support and correct screen reader announcements without custom ARIA. I'd test that reset() leaves the form pristine, disable() blocks input, blur marks it touched, and the keyboard can change the value, not only the mouse.
@Component({
selector: 'app-star-rating',
templateUrl: './star-rating.html', // five native radio inputs
providers: [
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true },
],
})
export class StarRating implements ControlValueAccessor {
protected value = signal<number | null>(null);
protected disabled = signal(false);
private onChange: (v: number) => void = () => {};
protected onTouched: () => void = () => {};
writeValue(v: number | null) { this.value.set(v); }
registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(isDisabled: boolean) { this.disabled.set(isDisabled); }
protected select(rating: number) {
this.value.set(rating);
this.onChange(rating);
}
}ControlValueAccessor with NG_VALUE_ACCESSORwriteValue updates the display and never calls onChangeonTouched on blur; handle setDisabledStatereset(), disable(), blur and keyboard inputLikely follow-up: If the control starts at 0 instead of null, why does Validators.required pass, and what would you use instead?
Version comes first. Signal Forms don't exist in Angular 19 or 20; they arrived in v21 as an experimental API in @angular/forms/signals. On 19 or 20 the real first step is the upgrade, and I'd check the API's status in the target version.
The appeal is real. form(model) wraps a signal you already have instead of copying the value, validation and conditional rules live in one schema function, and a cross-field rule reruns when either field changes, with no valueChanges subscriptions.
Then the cost. I'd prototype one representative form, not a toy: async validation, server errors mapped onto fields, a custom control and a reset. I'd also list every custom ControlValueAccessor and third-party control we use, since each needs to work with the new field binding.
Reactive forms remain fully supported, so there's no deadline. For an app with hundreds of working reactive forms, I'd build new forms with Signal Forms and migrate old ones only when they're being rewritten anyway, keeping behavior tests for validation, reset and submit.
form(model) wraps your signal instead of copying itControlValueAccessor and third-party controlsLikely follow-up: What problem with reactive forms does wrapping the model signal solve?
A guard is a UX feature. It decides what the browser shows, but it runs on the user's machine, so anyone can bypass it by editing the JavaScript or calling the API directly. Real protection lives on the server: every endpoint checks the session and the permission and answers 401 or 403.
On the client, a functional CanActivateFn sends signed-out users to /login by returning router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } }). Returning a UrlTree lets the router treat it as a clean redirect. For lazy features I use canMatch: when it fails, the router doesn't even download the chunk.
After login, I follow returnUrl only if it's an in-app path starting with a single /. router.navigateByUrl() stays inside the app, but if that value ever reaches window.location or a server-side login page, an unchecked //evil.com becomes an open redirect.
Sessions also expire mid-use, so an interceptor handles 401 by clearing state and redirecting to login, and 403 with a no-access page. Tokens never go in query parameters.
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isSignedIn()
? true
: router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
export const routes: Routes = [
{ path: 'account', component: AccountPage, canActivate: [authGuard] },
{ path: 'billing', canMatch: [financeOnly], loadChildren: () => import('./billing/routes') },
];UrlTree with a returnUrlcanMatch stops the lazy chunk from loadingreturnUrl pathsLikely follow-up: What's the difference between canActivate and canMatch on a lazy-loaded route?
A parent route renders the shared layout and child routes fill its <router-outlet />. Here OrdersLayout holds the tabs and outlet; the screens are children. The whole feature sits behind loadChildren, so none of it downloads until someone opens /orders.
Two lifetime decisions matter. Services in the route's providers are shared by the layout and every child, but that injector is created on the first visit and, by default, lives as long as the app. That's fine for a cache of orders and wrong for per-visit state like a wizard draft, which belongs in the layout component's own providers so it's destroyed on exit.
The second is component reuse. Going from /orders/1 to /orders/2 keeps the same OrderDetail instance, so ngOnInit doesn't run again. Read the id reactively, through an id = input() with withComponentInputBinding() or the paramMap Observable, not from snapshot once.
I'd test deep links on a fresh load, refresh, back and forward, and switching between ids.
// app.routes.ts
{ path: 'orders', loadChildren: () => import('./orders/orders.routes') },
// orders/orders.routes.ts
export default [
{
path: '',
component: OrdersLayout, // tabs plus <router-outlet />
providers: [OrdersApi],
children: [
{ path: '', component: OrderList },
{ path: ':id', component: OrderDetail },
{ path: ':id/edit', component: OrderEdit, canDeactivate: [unsavedChangesGuard] },
],
},
] satisfies Routes;<router-outlet /> and child routesloadChildren for the whole feature:id changes reuse the same componentLikely follow-up: How would you keep the list's filters and scroll position when the user returns from a detail page?
Share one refresh. The auth service keeps the in-flight refresh as a single Observable, the refresh POST piped through shareReplay(1), created by the first 401 and cleared when it settles. Every request that gets a 401 waits on that same Observable, then retries once with the new token. Ten parallel failures produce one refresh call.
Three guards stop loops. The refresh request itself carries an HttpContextToken flag such as SKIP_AUTH, so the interceptor never tries to refresh the refresh. Each original request retries only once, tracked with another context flag, so a 401 after a successful refresh goes to login instead of looping. And if the refresh fails, the service clears the tokens, fails every waiting request and sends the user to sign in.
The subtle case is identity. If someone else signs in while old requests are still waiting, those requests must not be replayed with the new person's token. Tagging requests with a session id and dropping retries from an older session prevents that.
shareReplay(1)HttpContextTokenLikely follow-up: Why can refresh-token rotation still break when two browser tabs refresh at the same time?
This interceptor has two bugs. With overlapping requests, the first one to finish sets active to false while the second is still running, so the spinner vanishes early. And tap({ complete }) covers only success. An error, or a request cancelled by switchMap or a destroyed component, never completes, so the spinner can stick forever.
I'd count instead: pending = signal(0) and isLoading = computed(() => this.pending() > 0). The interceptor increments before next(req) and decrements in finalize(), which runs on completion, error and unsubscribe alike.
Not every request should show it. Background polling or a silent prefetch sets a context flag, new HttpContext().set(SILENT, true), and the interceptor skips those. A global overlay should also wait 150 to 200 milliseconds before appearing, so fast requests don't flash it.
Finally, a global indicator only fits blocking actions. Refreshing one widget deserves a loading state on that widget, often straight from its resource or stream, not a full-page spinner.
export const loadingInterceptor: HttpInterceptorFn = (req, next) => {
const loading = inject(LoadingService);
loading.active.set(true);
return next(req).pipe(
tap({ complete: () => loading.active.set(false) }),
);
};finalize() for complete, error and cancelHttpContextTokenLikely follow-up: Where would you add a delay so fast requests don't flash the spinner?
I'd drive it the way a user would and control the network at the HTTP layer. TestBed gets provideHttpClient() and provideHttpClientTesting(), and the test injects HttpTestingController, so the real service and the real subscription code stay in play.
The test types a comment and clicks Post. http.expectOne('/api/comments') returns the pending request, and flush('Oops', { status: 500, statusText: 'Server Error' }) fails it. For a network failure, req.error(new ProgressEvent('error')) is the closer simulation. After await fixture.whenStable(), I assert what the user sees: the error message, and the textarea still holding their text.
Then the retry: click the retry button, expect a second request, flush a success, and assert the success state. http.verify() at the end fails the test if a request was left unhandled or an extra one was sent.
What I avoid is setting a private error field and checking the template, because that skips the code most likely to be broken: the subscription, the error mapping and the retry wiring.
it('keeps the comment and retries after a server error', async () => {
TestBed.configureTestingModule({
imports: [CommentBox],
providers: [provideHttpClient(), provideHttpClientTesting()],
});
const fixture = TestBed.createComponent(CommentBox);
const http = TestBed.inject(HttpTestingController);
const el: HTMLElement = fixture.nativeElement;
await fixture.whenStable();
const box = el.querySelector('textarea')!;
box.value = 'Great post';
box.dispatchEvent(new Event('input'));
el.querySelector<HTMLButtonElement>('[data-test=post]')!.click();
http.expectOne('/api/comments').flush('Oops', { status: 500, statusText: 'Server Error' });
await fixture.whenStable();
expect(el.textContent).toContain('Could not post your comment');
expect(box.value).toBe('Great post');
el.querySelector<HTMLButtonElement>('[data-test=retry]')!.click();
http.expectOne('/api/comments').flush({ id: 7, text: 'Great post' });
await fixture.whenStable();
expect(el.textContent).toContain('Comment posted');
http.verify();
});provideHttpClientTesting() with HttpTestingControllerflush() a 500, or error() for network failuresverify()Likely follow-up: How would you test a retry policy that waits between attempts without slowing the test down?
/orders/:id?midI'd use the real route configuration with RouterTestingHarness instead of mocking ActivatedRoute. TestBed provides provideRouter(routes, withComponentInputBinding()) plus the HTTP testing providers. Then const harness = await RouterTestingHarness.create() and await harness.navigateByUrl('/orders/1', OrderDetail) render the component the way the router would, guards and resolvers included.
The test that catches real bugs moves to a second id on the same route with harness.navigateByUrl('/orders/2'). The router reuses the same OrderDetail instance, so code that read snapshot.paramMap once in ngOnInit keeps showing order 1. I'd assert that the second request goes to /api/orders/2 and the heading updates.
Then the edge cases: an unknown id that returns 404 should show a not-found state or redirect, and a guarded URL for a signed-out user should end on the login page, which TestBed.inject(Router).url confirms.
A mocked ActivatedRoute with a fixed of({ id: '1' }) can't catch any of this, because the parameter never changes.
RouterTestingHarness with the real routes/orders/1 to /orders/2ActivatedRoute hides these bugsLikely follow-up: How would you test a canDeactivate guard that warns about unsaved changes?
I'd review everything the server sends, not just what's visible. Fetch the raw HTML with curl or the Network tab and look for three things.
provideClientHydration(), the HTTP transfer cache serializes server-side GET responses into a JSON <script> in the page. Requests with an Authorization header are skipped by default, but cookie-authenticated ones aren't, so a full user record can end up in the HTML even when the template shows only a name.let currentUser at module scope leaks one user's data into another user's page.Cache-Control: private or no-store, or a CDN may serve it to the next visitor. A route set to RenderMode.Prerender bakes its data into a static file at build time.Then I'd render the same URL for two users concurrently and compare the responses.
GET responses get serializedCache-Control: private for personalized pagesLikely follow-up: How would you keep one sensitive request out of the HTTP transfer cache?
NG0500 errors say what Angular expected at a position and what it found, such as expected <div> but found <canvas>. Angular's hydration compares DOM structure, meaning nodes and tag names, not text, so the cause is something adding, removing or moving elements.
With a third-party widget, that's usually one of two things. Either the library ran during server rendering and produced markup the client doesn't recreate, or it ran in the browser before hydration finished and rewrote its container, so Angular finds nodes it didn't create.
The fix is starting the widget in afterNextRender(), which runs only in the browser and after hydration, in a component whose template is an empty container. If the library can't be tamed yet, ngSkipHydration on that component's host element makes Angular discard its server DOM and render it fresh on the client. It only works on component hosts, so treat it as a quarantine.
I'd also rule out invalid HTML, like a <div> inside a <p>, which the browser silently restructures.
afterNextRender()ngSkipHydration on the host is a temporary quarantineLikely follow-up: Why does afterNextRender() avoid the mismatch when ngOnInit doesn't?
@defer block, and how would you check that it helped?midHeavy content that isn't needed for the first view: a chart dashboard below the fold, a rich-text editor behind an Edit button, a comments section, a map. Writing @defer (on viewport) { <sales-chart /> } @placeholder { <div class="chart-skeleton"></div> } loads the chart's code only when the placeholder scrolls into view. For the editor, on interaction fits better, with prefetch on idle so the click doesn't wait on the network.
I wouldn't defer the main content, the LCP element or anything needed for the first interaction, and the placeholder should reserve the final height so the layout doesn't jump.
Then I'd verify, because a defer block can silently save nothing. A dependency is split out only if it's standalone and nothing else in the same file references it; a @ViewChild on it, or one more use outside the block, keeps it in the eager bundle. The build output should list a new lazy chunk. And with SSR, the server renders only the placeholder, unless incremental hydration with hydrate on triggers is in use.
on viewport, on interaction, prefetch on idleLikely follow-up: What do the parameters in @loading (after 100ms; minimum 1s) prevent?
First I'd measure which part is slow, because the fixes differ. The Angular DevTools profiler and the Chrome Performance panel separate network time, change detection and DOM work.
If rendering is the cost:
@for (row of rows(); track row.id) lets a refresh update existing rows instead of recreating them.{{ formatDate(row.date) }}, run for every row on every check. A pure pipe or precomputed values fix that.computed() signals, with the filter input debounced.OnPush row components mean editing one row doesn't check thousands.If there are simply too many rows, page and sort on the server, or render only the visible window with the CDK's cdk-virtual-scroll-viewport. Virtual scrolling has costs: browser find can't see rows that aren't rendered, focus can be lost when a row scrolls out, and screen readers need aria-rowcount and aria-rowindex.
I'd test with production-sized data, say 10,000 rows with live updates, not a 20-row fixture.
track row.id and no method calls in cellscomputed() filtering and OnPush rowscdk-virtual-scroll-viewportLikely follow-up: When would you choose server-side paging over virtual scrolling?
Angular already sanitizes values bound with [innerHTML]: it removes <script> elements and on* attributes such as onerror, neutralizes javascript: URLs, and logs a warning in development when it strips something. So the first rule is not to undo that. Calling DomSanitizer.bypassSecurityTrustHtml() on user content turns a stored comment into stored XSS.
Angular's sanitizer is a safety net, not a content policy. I'd define what the product allows, say paragraphs, lists, links, bold and images from our CDN, and enforce it on the server when content is saved, with an allowlist sanitizer such as DOMPurify. That also protects emails or a mobile app that render the same HTML without Angular.
User content is never compiled as an Angular template; that would be code execution, not markup. Links get rel="noopener noreferrer" and only http, https or mailto schemes.
A Content Security Policy with Trusted Types adds defense in depth. I'd test with stored payloads like <img src=x onerror=alert(1)>, including rows saved before the policy existed.
[innerHTML] is sanitized automaticallybypassSecurityTrustHtml() on user contentLikely follow-up: Why sanitize on the server when Angular already sanitizes in the browser?
I'd treat the tenant as part of every key. It goes in the URL, /t/:tenantId/..., and an interceptor adds it to API calls from one currentTenant signal.
On a switch, three things go wrong if you're not careful:
/t/a to /t/b reset on their own.switchMap or a resource cancel old requests, and any response whose tenant doesn't match the current one is dropped.A full page load on switch is a legitimate, simple option in a complex app. And the server checks membership on every request, never trusting the tenant id the client sends.
Likely follow-up: Besides your own stores, what in the browser can still hold the old tenant's data?
No. Material gives you good building blocks: MatDialog traps focus and returns it to the trigger on close, mat-form-field links mat-error messages to the input, and menus and selects support the keyboard. Putting them together correctly is still your job.
On a Material page I'd review:
mat-label, and icon-only buttons such as mat-icon-button have an aria-label.LiveAnnouncer.matSort describe the sort state, and the paginator works from the keyboard.Then testing. axe in CI catches some issues, but I'd also walk the main flows with only a keyboard and with a screen reader such as NVDA or VoiceOver, including errors and dialogs.
LiveAnnouncer for async updates; page titlesLikely follow-up: What does cdkFocusInitial change when a dialog opens?
Overlay clean up?midThe key fact is that overlay content doesn't live inside your component. overlay.create() appends a pane to .cdk-overlay-container under body, so destroying the component that opened it doesn't dispose the overlay: the pane, backdrop and keyboard listeners stay behind. I call overlayRef.dispose() on close and again in DestroyRef.onDestroy. dispose() detaches the portal, destroys the attached view, removes the pane and backdrop, and completes streams like backdropClick(), keydownEvents() and detachments(). I use detach() only when the same overlay will be reattached.
Subscriptions I added to those streams, plus any manual document listeners, end at the same point. Scroll behavior comes from a scroll strategy such as reposition(), close() or block(), not hand-written scroll listeners.
Focus is the other half. Save document.activeElement before opening, trap focus inside a modal panel with cdkTrapFocus, and restore focus to the trigger on close.
Then test the awkward cases: open and close it twenty times and check there's only one pane, change routes while it's open, and close it with Escape.
body, outside your componentdispose() on close and in DestroyRef.onDestroybackdropClick() and keydownEvents()Likely follow-up: What's the difference between detach() and dispose() on an OverlayRef?
I'd order them from fast to slow so failures show up early:
npm ci, so CI builds exactly what was tested locally.ng build with strictTemplates: true catches template type errors, wrong input names and missing imports. Plain tsc doesn't check templates at all.ng test in headless mode, whether the runner is Karma, Jest or Vitest. Component tests should drive the DOM, not set private fields.angular.json, for example failing if the initial bundle passes 1 MB. A budget failure catches the dependency someone imported by accident.I'd also read build warnings, such as CommonJS dependency warnings, and fail on new ones. In a large repo, running only affected projects, with nx affected or similar, keeps CI fast.
npm ci, lint and strictTemplatesLikely follow-up: What kinds of bugs does strictTemplates catch that unit tests often miss?
I'd look at the real output first. ng build --stats-json writes a stats file you can load into the esbuild bundle analyzer, or I'd run source-map-explorer on the built files. Either shows which modules ended up in the route's chunk and in the initial bundle. Then the Network tab shows which chunks actually load when I open the route.
The usual causes:
shared/index.ts can drag in everything it re-exports.lodash instead of lodash-es, moment with every locale, or a chart library imported by a root service.The fixes are direct imports instead of barrels, import() inside the function that needs a heavy library, and @defer for heavy widgets. Then rebuild and compare chunk sizes, because adding loadComponent doesn't help while the code is still imported eagerly somewhere else.
ng build --stats-json and a bundle analyzerlodash or momentLikely follow-up: Why can importing through a barrel file defeat tree shaking?
I start with the simplest thing that handles the real workflows. For most features, a service with private signals, computed() values and methods is enough: a cart, a filter panel, a settings page. It's plain TypeScript, easy to test, and scoped wherever it's provided.
I'd consider NgRx when the workflows get hard, not merely when the app gets big. Signs are many features reading and writing the same entities; complex async flows such as optimistic updates with rollback, WebSocket events and undo; a need to see every state change in Redux DevTools; or a large team that benefits from one enforced pattern. NgRx Store gives actions, reducers and effects with that traceability, at the cost of more files and indirection. NgRx SignalStore sits in between: signalStore(withState(...), withComputed(...), withMethods(...)) keeps the signal style with more structure.
To decide, I'd build one real hard workflow both ways, say approving an invoice while live updates arrive, and compare the code. Adopting a global store just because state exists turns simple code into ceremony.
computed(), methodsLikely follow-up: How would you move a feature from a signal service to SignalStore without changing its components?
First, what's safe to keep. UI preferences, filters and unsent drafts are good candidates. Access tokens and personal data aren't: anything in localStorage can be read by any script on the page, including an XSS payload. Loading flags and error messages don't belong there either; a reload shouldn't bring back a spinner.
Second, a versioned format such as { version: 2, data: {...} }. On load I parse inside try/catch, validate the shape, and either migrate old versions, for example renaming a field from v1 to v2, or discard what I can't read. Bad data in storage shouldn't crash the app at startup.
Third, scope. Keys include the user id, like prefs:42, and are cleared on sign-out, so a shared computer doesn't show one person's drafts to the next.
Mechanically, an effect() saves the signal's value, debounced if it changes often, and storage is read only in the browser, since localStorage doesn't exist during SSR. The storage event keeps other open tabs in sync.
try/catch and discard bad datalocalStorage during SSRLikely follow-up: How would you keep persisted state in sync between two open tabs?
The main question is whether users switch language at runtime. Angular's built-in i18n, @angular/localize, compiles one build per locale: you mark text with the i18n attribute or $localize, extract it with ng extract-i18n, and ng build --localize produces /en/, /fr/ and so on. It's fast and catches missing translations at build time, but changing language means loading a different URL. Runtime libraries such as Transloco or ngx-translate load JSON per language and switch instantly, at the cost of runtime lookups and fewer build-time checks.
Then the rest of localization. Plurals need ICU messages, for example {count, plural, =0 {No items} =1 {One item} other {{{count}} items}}, not string concatenation. Dates, numbers and currency go through pipes driven by LOCALE_ID. Arabic and Hebrew need dir="rtl" and CSS logical properties like margin-inline-start.
With SSR, the server must pick the locale from the URL or a cookie and render in it, or users see a flash of the wrong language. I'd test with German, for long text, and an RTL locale.
@angular/localize versus runtime librariesLOCALE_ID pipes and RTL with logical propertiesLikely follow-up: How does one build per locale affect deployment and URLs?
The default, ViewEncapsulation.Emulated, suits most widgets. Angular rewrites the component's selectors with generated attributes like _ngcontent-abc, so styles don't leak out, while global styles such as typography and theme CSS still apply inside.
ShadowDom uses a real shadow root, so isolation works in both directions. Global styles stop applying inside, so fonts, resets or a component library's global CSS can vanish. CSS custom properties still inherit into the shadow tree, so they become the theming API. It's mostly worth it for widgets embedded in pages you don't control, like Angular Elements on third-party sites.
None makes the styles global, which suits only deliberate global styles.
Whichever mode, consumers need supported hooks: custom properties like --rating-color, :host classes for variants, and ::part() names with ShadowDom. Otherwise they reach into private markup with the deprecated ::ng-deep and break on your next refactor.
One gotcha: CDK overlay content moves under body. Emulated content keeps its attributes and stays styled, but content portaled out of a shadow root loses the component's styles.
ShadowDom blocks global styles; custom properties still inheritNone makes styles global:host classes::ng-deep; watch overlay contentLikely follow-up: How would you let consumers theme a widget that uses ShadowDom?
The configuration should only choose from code we ship. A registry maps each type string to a lazy import, as in the snippet, so every widget gets its own chunk and an unknown type renders a fallback instead of failing. I'd never compile template strings from the server: AOT builds don't include the compiler, and running server-provided markup as a template is code execution.
Rendering uses supported APIs: NgComponentOutlet with its inputs option in the template, or ViewContainerRef.createComponent() followed by ref.setInput(). Angular 20 also accepts bindings: [inputBinding(...)] in createComponent. A widget that needs its own services gets an injector built with Injector.create, providing its config under an injection token.
Each widget's settings are validated against a schema before rendering, and the APIs a widget calls check permissions on the server, because the config is just data a client can tamper with. Removing a widget calls ref.destroy(), or clear() on the container, which runs its cleanup. I'd also version the config format so saved layouts survive widget changes.
const WIDGETS: Record<string, () => Promise<Type<unknown>>> = {
revenue: () => import('./widgets/revenue').then((m) => m.RevenueWidget),
tickets: () => import('./widgets/tickets').then((m) => m.TicketsWidget),
};
// In the dashboard component
async addWidget(config: WidgetConfig) {
const load = WIDGETS[config.type];
if (!load) return this.showUnknown(config.type);
const ref = this.host.createComponent(await load());
ref.setInput('settings', config.settings);
this.refs.push(ref);
}NgComponentOutlet or createComponent() with setInput()Likely follow-up: How would you stop one broken widget from taking down the whole dashboard?
The Angular service worker caches the app shell and, if configured, GET responses, but it doesn't queue writes: a POST sent offline just fails. The feature needs its own outbox:
crypto.randomUUID(), which the server treats as an idempotency key so a replay doesn't create a duplicate.concatMap. navigator.onLine only means a network interface exists, so a real request confirms connectivity.navigator.storage.persist() and watch the quota. The queue is keyed by user, so one person's changes never replay under another account.GET responses, not offline writesLikely follow-up: Two people edited the same record offline. How would you resolve the conflict?
I'd use the official schematic and go in steps. ng generate @angular/core:standalone has three modes, run in order on a path: convert declarations to standalone, remove NgModules that are no longer needed, and switch bootstrapping to bootstrapApplication. Going folder by folder keeps pull requests reviewable, with tests for key screens written first.
Converting a component moves what its module used to supply into the component's own imports, such as NgClass or RouterLink. Components are standalone by default since Angular 19, so anything still declared in a module carries standalone: false, added by the v19 update.
Providers need judgment. Services in a lazy module's providers had one instance per lazy module; switching them to providedIn: 'root' silently makes them app-wide singletons. To keep the same scope, list them in the lazy route's providers. Library modules that are still NgModule-based go through importProvidersFrom().
Lazy routes change from loadChildren returning a module to returning a Routes array, or to loadComponent. Finally I'd check the lazy chunks are unchanged and deep links work.
ng generate @angular/core:standalone, step by stepimportsproviders, not rootimportProvidersFrom() for NgModule librariesLikely follow-up: What does standalone: false mean in Angular 19, and why did the update add it?
Prerequisites first. The update guide on angular.dev lists the steps for each version pair. Angular 20 needs TypeScript 5.8 and drops Node 18, so CI images and local Node versions may have to move before anything else. I'd also confirm that every Angular-dependent library, such as Angular Material, NgRx or a component vendor, has a release for the new major.
Then one major at a time, on a branch: ng update @angular/core@20 @angular/cli@20, then the other Angular packages. ng update runs migrations, like the v19 one that added standalone: false, and I review those diffs instead of trusting them blindly. I also read the breaking-changes list for things no migration fixes; v20, for instance, renamed afterRender to afterEveryRender.
Verification covers more than a green build: unit and end-to-end tests, forms, routing, SSR and hydration if used, and a bundle size comparison with the old build.
Finally, release it like any risky change, with a staged rollout, error monitoring and a fast way to redeploy the previous build.
ng updateLikely follow-up: What would you do if a critical third-party library doesn't support the new major yet?
I'd follow the WAI-ARIA combobox pattern, or use MatAutocomplete, which implements it. The input has role="combobox", aria-expanded and aria-controls pointing at a role="listbox". Focus stays in the input while arrow keys move a highlighted option, exposed through aria-activedescendant; Enter selects and Escape closes. Options listen to (mousedown) with preventDefault() so clicking one doesn't blur the input first.
The query stream is the usual pipeline: trim, debounceTime(250), distinctUntilChanged(), and switchMap so stale responses are discarded. The panel has loading, 'No matches' and error states, and options use track option.id so the highlight survives a refresh.
Announcements need restraint: one polite live-region message after results settle, like '5 suggestions', beats announcing every keystroke.
IME input matters for Japanese, Chinese or Korean users. Angular's DefaultValueAccessor holds form updates until compositionend on desktop browsers, but a raw (input) listener doesn't, so that code should check event.isComposing. I'd test slow responses, typing during a pending request, keyboard-only use and a screen reader.
aria-activedescendantdebounceTime and switchMap for queriesLikely follow-up: Why does aria-activedescendant work better here than moving real focus into the list?
Requirements first, because they drive everything: who uses it (support agents, finance staff, super admins), the main workflows (find a customer, issue a refund, review audit logs), how fresh the data must be, and how many teams work on it.
Then structure. Each domain, like billing or reports, is a lazy-loaded route tree with its own signal-based services and typed API clients generated from OpenAPI. A shell provides the layout and builds the menu from the user's permissions. Shared UI sits in a library built on Angular Material and design tokens, and lint rules stop features importing each other.
Security gets its own sentence: canMatch guards hide what a user can't use, but every endpoint authorizes on the server and sensitive actions write to an audit log.
Then cross-cutting parts: an interceptor for 401, 403 and retries, a global ErrorHandler that reports to monitoring, server-side paging, accessibility checks, and tests for services, components and key Playwright flows.
I'd end with one real trade-off I made, not a list of libraries.
Likely follow-up: How would you split this portal if four teams need to release independently?
Angular is a TypeScript-based framework from Google for building single-page applications. Unlike a view library, it's batteries-included: the router, forms, HttpClient, dependency injection, testing utilities, i18n, SSR and the CLI all ship as first-party packages. It's a complete rewrite of AngularJS (1.x), not an upgrade of it.
The main building blocks:
Templates are compiled ahead of time into efficient JavaScript instructions. Modern Angular is standalone-first: components declare their own imports, and the app starts with bootstrapApplication() instead of a root NgModule.
Likely follow-up: What is the difference between a framework and a library like React?
@Component decorator?easyA component is the basic UI building block: a TypeScript class that holds state and behavior, paired with a template that describes the DOM and optional styles. Angular creates an instance wherever its selector matches in a template.
The @Component metadata tells the compiler how to treat the class:
selector: how it's used in templates, usually an element name like app-user-card.template or templateUrl: the HTML.styles or styleUrl: CSS, scoped to this component by default.imports: the components, directives and pipes the template uses.providers, changeDetection, encapsulation and host bindings.In current Angular, components are standalone by default, so each one declares its own template dependencies instead of relying on an NgModule's declarations. Keep the class focused on presentation; data access and shared state belong in services.
selector, template, styles, imports are the core metadataimports lists template dependenciesLikely follow-up: What kinds of selectors can a component use?
Data flows between the class and the template in four ways:
{{ expr }}: one-way, class to view. Renders the value as text.[prop]="expr": one-way, class to view. Sets a DOM or component property, e.g. [disabled]="saving". Variants: [attr.aria-expanded] for attributes, [class.active] and [style.width.px].(event)="handler($event)": one-way, view to class, for DOM events and component outputs.[(value)]="x": sugar for a property binding plus a valueChange event binding, as used by [(ngModel)] or a model() input.A detail interviewers like: property binding targets DOM properties, not HTML attributes. Attributes only initialize properties, so an attribute with no matching property, such as data-*, is bound with [attr.name]. Template expressions should be cheap and side-effect free, because they're re-evaluated during change detection.
<h2>{{ title }}</h2>
<img [src]="avatarUrl" [alt]="name" />
<button
[attr.aria-expanded]="open"
[class.active]="open"
(click)="toggle()">
Menu
</button>
<input [(ngModel)]="query" />[()] is property binding plus a Change event[attr.x] for attributesLikely follow-up: What is the difference between an HTML attribute and a DOM property?
A directive is a class that adds behavior to elements in a template. There are three kinds:
ngClass, ngStyle, ngModel, or a custom appHighlight.*ngIf, *ngFor, *ngSwitchCase. The asterisk is sugar: Angular wraps the element in an <ng-template>, and the directive uses TemplateRef and ViewContainerRef to decide when to render it.Only one structural directive can sit on an element; nest them with <ng-container>. In modern templates, the built-in control flow blocks @if, @for and @switch replace the common structural directives: they're part of the template syntax, need no import, and NgIf/NgFor are now deprecated. Custom structural directives are still useful for things like permission checks.
ng-template* is sugar for <ng-template>; one per element@if/@for/@switch replace *ngIf/*ngFor/ngSwitchLikely follow-up: How would you write a custom structural directive?
Lifecycle hooks are methods Angular calls at specific moments. After the constructor, the order is:
ngOnChanges: before ngOnInit when the component has bound inputs, then every time an input changes. Receives SimpleChanges.ngOnInit: once, after the first inputs are set. Initialization logic goes here.ngDoCheck: on every change detection run, for custom change checks.ngAfterContentInit, then ngAfterContentChecked: projected content is initialized, then checked.ngAfterViewInit, then ngAfterViewChecked: the component's view and its child views are initialized (view queries resolved), then checked.ngOnDestroy: once, before the component is destroyed. Clean up here.On later passes only ngOnChanges (if an input changed), ngDoCheck and the two Checked hooks run again. Modern additions: inject(DestroyRef).onDestroy() registers cleanup from anywhere in an injection context, and afterNextRender()/afterEveryRender() are for DOM work and run only in the browser.
ngOnChanges, ngOnInit, ngDoCheckInit hooks run once; Checked hooks every passngOnDestroy or DestroyRef for cleanupafterNextRender for browser-only DOM workLikely follow-up: Why would ngOnChanges not fire when you mutate an input object?
ngOnInit?easyThe constructor is a plain TypeScript class feature. Angular calls it when it instantiates the component, before any inputs are bound. It's the place to inject dependencies and set simple defaults, and nothing that depends on inputs: reading a required input() there throws, and a classic @Input() still holds its default or undefined.
ngOnInit is an Angular lifecycle hook that runs once, after the first ngOnChanges, when input values are available. Initialization that depends on inputs belongs there, like starting a fetch based on an id input.
Rules of thumb:
ngAfterViewInit or afterNextRender() for DOM access.ngOnInit jobs become a computed() derived from inputs, or an effect() created in the constructor.ngOnInit: Angular hook, runs once after first inputsngOnInit for input-dependent setupDependency injection is a pattern where a class declares what it needs and the framework supplies it, instead of the class constructing its dependencies itself. In Angular you request a token (usually a class) with inject(UserService) or a constructor parameter, and an injector finds a provider that says how to create the value.
Providers are registered in a few places:
@Injectable({ providedIn: 'root' }) for an app-wide singleton.providers array of the ApplicationConfig, a route, or a component.Injectors form a hierarchy. Angular first walks the element injectors up the component tree, then the environment injectors (route, root, platform). The first provider found wins, and its injector owns the instance. If nothing is found, you get a no-provider error unless the dependency was marked optional.
The payoff is loose coupling, easy testing (swap a real service for a fake in TestBed), and control over each dependency's scope and lifetime.
providedIn, app config, route or component providersLikely follow-up: What does providedIn: 'root' do? · How can the same service end up with two instances?
providedIn: 'root' do?easyA service is a class for logic that doesn't belong to one component's view: fetching data, shared state, logging, auth. It's marked with @Injectable() so Angular can create it and inject its own dependencies.
@Injectable({ providedIn: 'root' }) registers the service with the root environment injector, which gives you:
providers array is always bundled."Singleton" really means one instance per injector. If you also list the service in a component's providers, that component and its children get their own separate instance, and a route's providers does the same for that route's subtree. That's handy for deliberately scoped state, but it's also the usual cause of "my service state keeps resetting".
providedIn: 'root': one lazy, tree-shakable app-wide instanceproviders create extra instanceshttp.get() sends two requests.HttpClient aborts the request.of(1)) or asynchronously.map, switchMap, retry and debounceTime.Angular uses Observables for HttpClient, router events and form valueChanges. To bridge the two, firstValueFrom() and lastValueFrom() turn an Observable into a Promise, and from(promise) goes the other way.
firstValueFrom bridges to PromisesLikely follow-up: What is the difference between a hot and a cold Observable?
Change detection keeps the DOM in sync with component state. Each compiled template has an update function that re-evaluates its bindings, compares each result with the previous value (by identity, not deep equality), and touches only the DOM that changed. A pass walks the component tree top-down from the root, so parents are checked before their children.
Two questions matter: when a pass runs, and which components it checks.
ApplicationRef.tick() after each task. Zoneless apps, the default in recent versions, schedule a pass only when Angular is notified: a signal read in a template changes, a template listener fires, markForCheck() is called (the async pipe does this), or an input is set.OnPush components are checked only when marked dirty, and signals let Angular refresh just the views that read the changed signal.In development mode Angular runs an extra check and throws NG0100 (ExpressionChangedAfterItHasBeenChecked) if a binding changed in between.
Likely follow-up: What does OnPush change? · What triggers change detection in a zoneless app?
It depends on how the components are related:
input() (classic @Input()) and the parent binds [todo]="item".output() (classic @Output() with an EventEmitter) and calls emit(); the parent listens with (toggled)="onToggle($event)".model() enables [(value)] two-way binding.viewChild(TodoItem), or a template reference variable, gives the parent the child instance.BehaviorSubject, provided at the right level.Content projection with <ng-content> is the other composition tool: the parent passes markup instead of data. When lots of state is shared across a large app, a store (NgRx or a signal-based store) formalizes the service approach.
input() or @Input()output() or @Output() eventsviewChild() or a template refLikely follow-up: When would you reach for a state management library instead of a service?
Pipes transform values for display in templates: {{ price | currency:'EUR' }}, {{ createdAt | date:'short' }}. Built-ins include date, currency, decimal, percent, uppercase, titlecase, slice, json, keyvalue and async.
transform only when the input value or an argument changes: a different primitive or a new object reference. Mutating an array in place doesn't re-run them. That makes them cheap, effectively memoized per binding.pure: false) run on every change detection pass, so they must be fast. async, json and keyvalue are impure because their output can change while the input reference stays the same.A custom pipe is a class decorated with @Pipe({ name }) that implements PipeTransform. Pipes are standalone by default, so you add them to a component's imports. Prefer a pure pipe (or a computed() signal) over calling a method in the template, which re-runs on every check.
@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 20): string {
return value.length > limit ? value.slice(0, limit) + '…' : value;
}
}
// In a component with imports: [TruncatePipe]
// {{ post.title | truncate:40 }}|async, json)@Pipe + PipeTransform, then add to importsLikely follow-up: Why does a pure pipe not update when you push to an array?
NgModules were Angular's original unit of compilation. A module listed components in declarations, pulled in dependencies through imports, shared things with exports and registered providers. A component could only use what its module made available, which led to lots of indirection and "shared modules".
A standalone component lists its own template dependencies in its imports array, so it's self-contained. It's the default in current Angular, and the rest of the app follows the same style:
bootstrapApplication(App, appConfig) instead of bootstrapModule(AppModule).provideRouter(routes) and provideHttpClient() instead of RouterModule.forRoot() and HttpClientModule.loadComponent.The benefits are less boilerplate, dependencies visible in one place, and simpler lazy loading and testing. The two styles interoperate: a standalone component can import an NgModule, and an NgModule can import a standalone component. The CLI ships a migration: ng generate @angular/core:standalone.
importsbootstrapApplication and provide*() functions replace root modulesOnPush change detection strategy do, and when does an OnPush component update?midWith ChangeDetectionStrategy.OnPush, Angular checks a component only when its view has been marked dirty. Otherwise the traversal skips it and, unless something inside was flagged, its whole subtree. A component is marked dirty when:
setInput()),async pipe receives a value,markForCheck() is called,What does not update it: mutating an input object in place, or assigning a plain field inside a setTimeout, a promise or a subscribe callback. That's why OnPush code uses immutable updates (spread, map, filter) and signals or the async pipe for async data.
The payoff is fewer checks in large trees and a more predictable data flow. In the newest Angular versions OnPush is the default when changeDetection isn't set, and the check-always strategy is named Eager (previously Default).
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h3>{{ user().name }}</h3>`,
})
export class UserCard {
readonly user = input.required<User>();
}
// In the parent, bound as [user]="user":
this.user.name = 'Grace'; // same reference: card stays stale
this.user = { ...this.user, name: 'Grace' }; // new reference: card updatesasync pipe, signalLikely follow-up: How do you update an OnPush component from a setTimeout callback?
switchMap, mergeMap, concatMap and exhaustMap?midAll four map each source value to an inner Observable, subscribe to it and flatten its values into one stream. They differ only in what happens when a new value arrives while the previous inner Observable is still running:
switchMap cancels the previous inner and switches to the new one. Use it when only the latest result matters: typeahead search, loading details for the current route param.mergeMap runs inners concurrently, optionally with a concurrency limit. Use it for independent parallel work where order doesn't matter.concatMap queues values and runs inners one at a time, in order. Use it for sequential writes such as autosave.exhaustMap ignores new values until the current inner completes. Use it to prevent double submits on a login or save button.Two points that impress interviewers: don't use switchMap for writes, because unsubscribing doesn't undo a request the server already received; and put catchError on the inner Observable, so one failed request doesn't kill the outer stream.
results$ = this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
);
saved$ = this.drafts$.pipe(concatMap((draft) => this.api.save(draft)));
loggedIn$ = this.submit$.pipe(exhaustMap(() => this.auth.login(this.form.getRawValue())));switchMap cancels the previous inner: typeahead, route paramsmergeMap runs in parallel; concatMap queues in orderexhaustMap ignores new values while busy: double submitsswitchMap for writes; catch errors inside the innerLikely follow-up: What happens to an HTTP request when switchMap unsubscribes from it?
Subject, BehaviorSubject, ReplaySubject and AsyncSubject?midAll four are both an Observable and an Observer, and they're multicast: every subscriber shares the same execution. The difference is what a late subscriber receives:
Subject: no initial value and no memory. Subscribers only get values emitted after they subscribe.BehaviorSubject: requires an initial value and always holds the current one. A new subscriber immediately gets the latest value, and you can read it synchronously with .value or getValue(). The classic choice for state in services.ReplaySubject(bufferSize, windowTime): no initial value; replays the last N values (optionally only those within a time window) to each new subscriber.AsyncSubject: emits only its last value, and only when it completes. Subscribers that arrive after completion get that value too, a bit like a Promise.Expose subjects from services through asObservable() so consumers can't call next(). In modern Angular, a signal() often replaces a BehaviorSubject for synchronous state.
const s = new Subject<number>();
s.next(1);
s.subscribe(console.log); // logs 2 only: 1 was emitted before subscribing
s.next(2);
const b = new BehaviorSubject(0);
b.next(1);
b.subscribe(console.log); // logs 1 immediately, then 2
b.next(2);
const r = new ReplaySubject<number>(2);
r.next(1); r.next(2); r.next(3);
r.subscribe(console.log); // logs 2, 3Subject: late subscribers get only future valuesBehaviorSubject: initial value, replays current value, .valueReplaySubject: replays the last N valuesAsyncSubject: emits the last value on completionasObservable()Likely follow-up: When would you use a signal instead of a BehaviorSubject?
You define a Routes array and register it with provideRouter(routes) in the application config (classic apps use RouterModule.forRoot(routes)). Each route maps a path to a component or to a lazy loadComponent/loadChildren, and can have children, redirectTo, guards, resolvers and data.
In templates:
<router-outlet /> marks where the routed component renders.routerLink="/users" or [routerLink]="['/users', id]" navigates without a full page reload, and routerLinkActive adds a class to the active link.In code, inject(Router).navigate(['/users', id]) navigates, and ActivatedRoute exposes paramMap, queryParamMap and data, as Observables or through snapshot. With withComponentInputBinding(), params, query params and resolved data are bound straight to component inputs.
Order matters: the router takes the first route that matches, so put specific paths before general ones and the ** wildcard last. An empty-path redirect needs pathMatch: 'full'.
export const routes: Routes = [
{ path: '', redirectTo: 'users', pathMatch: 'full' },
{ path: 'users', component: UserList },
{ path: 'users/:id', component: UserDetail },
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES) },
{ path: '**', component: NotFound },
];
bootstrapApplication(App, {
providers: [provideRouter(routes, withComponentInputBinding())],
});Routes array registered with provideRouter()<router-outlet> renders; routerLink navigatesActivatedRoute or input binding for params** goes lastLikely follow-up: What is the difference between paramMap and snapshot.paramMap?
Both are built on the same model classes (FormControl, FormGroup), but they differ in where the form model lives.
FormsModule): you describe the form in the template with ngModel and validation attributes such as required and minlength, and directives create the controls implicitly. The data model is mutable and bound two-way, and setup is asynchronous because the controls are created as the view renders. Good for simple forms: login, a search box, a settings toggle.ReactiveFormsModule): you build the model explicitly in the class with FormGroup, FormControl, FormArray or FormBuilder, and bind it with [formGroup] and formControlName. Access is synchronous, values are strongly typed, valueChanges and statusChanges are Observables, and validators are plain functions. That makes them easier to unit test and better for dynamic or complex forms.Most teams default to reactive forms for anything non-trivial. Recent Angular versions also add Signal Forms, a signal-based API in @angular/forms/signals.
ngModel, model implicit in the template[formGroup]Likely follow-up: How do you add a field dynamically in a reactive form?
Guards are functions the router runs to decide whether a navigation may proceed. Modern Angular uses functional guards, which can call inject():
canActivate: may the user enter this route? (auth, roles)canActivateChild: the same check for every child route.canDeactivate: may the user leave? Typically "you have unsaved changes".canMatch: may this route definition be used at all? If it returns false, the router tries the next matching route, and a lazy route's code isn't downloaded. It replaces the deprecated canLoad.A guard returns true/false, a UrlTree or RedirectCommand to redirect, or an Observable or Promise of one of those. Returning a UrlTree is better than calling router.navigate() inside the guard, because the router handles the redirect as part of the same navigation.
Class-based guards implementing CanActivate still work, but functions are simpler and composable. And guards are a UX feature, not security: the server must still enforce authorization.
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isLoggedIn() || inject(Router).createUrlTree(['/login']);
};
export const unsavedChangesGuard: CanDeactivateFn<EditPage> = (page) =>
!page.dirty() || confirm('Discard unsaved changes?');
export const routes: Routes = [
{ path: 'settings', component: EditPage, canActivate: [authGuard], canDeactivate: [unsavedChangesGuard] },
];canActivate, canActivateChild, canDeactivate, canMatchinject() for dependenciesUrlTree/RedirectCommand, or async versionscanMatch replaces canLoad and skips lazy downloadsLikely follow-up: In what order do guards and resolvers run?
Lazy loading splits the app into chunks that are downloaded only when needed, which shrinks the initial bundle and speeds up the first load. The build creates a separate chunk for every dynamic import().
For routes:
loadComponent: () => import('./settings/settings').then((m) => m.Settings) lazy loads one standalone component. If the file uses a default export, () => import('./settings/settings') is enough.loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES) lazy loads a whole set of child routes (in NgModule apps, a routing module).Related tools:
withPreloading(PreloadAllModules) downloads lazy chunks in the background after the app starts, so later navigations are instant.canMatch guards keep a chunk from being downloaded for users who can't use it.providers give a lazy section its own environment injector and scoped services.@defer lazy loads parts of a template rather than whole routes.Don't statically import a lazy component elsewhere, or it ends up back in the main bundle.
import() creates separate chunksloadComponent for one component, loadChildren for route setscanMatch prevents downloading unauthorized chunks@defer lazy loads template sectionsLikely follow-up: How would you write a custom preloading strategy?
signal, computed and effect.midA signal is a reactive wrapper around a value that notifies its consumers when the value changes. You read it by calling it, count(), which lets Angular track who depends on it.
signal(initial) creates a writable signal, changed with set() or update(fn). asReadonly() exposes a read-only view of it.computed(() => ...) derives a read-only value. It's lazy and memoized: it recalculates only after a signal it read changes, and it tracks dependencies dynamically.effect(() => ...) runs a side effect whenever the signals it read change. It runs at least once, runs asynchronously as part of change detection, needs an injection context, and is destroyed with the component or service that created it. Use it to sync with the outside world (storage, logging, a chart library), not to copy one signal into another; that's a computed().Signals compare with Object.is by default, so update immutably. Templates are reactive consumers too: a signal change refreshes only the views that read it, which makes OnPush and zoneless change detection practical. untracked() reads a signal without tracking it.
export class Cart {
readonly items = signal<Item[]>([]);
readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
constructor() {
effect(() => localStorage.setItem('cart', JSON.stringify(this.items())));
}
add(item: Item) {
this.items.update((list) => [...list, item]); // new array, not push()
}
}signal() holds writable state; read it by calling itcomputed() is lazy, memoized and read-onlyeffect() is for side effects, not for deriving stateObject.is equality by default: update immutablyLikely follow-up: How do signals differ from RxJS Observables? · What is linkedSignal for?
An interceptor is middleware for HttpClient: every request passes through the chain on the way out, and every response on the way back. Typical uses are auth headers, logging, global error handling, retries, caching and loading indicators.
In modern Angular an interceptor is a function of type HttpInterceptorFn. It receives the request and a next handler and returns next(req), optionally transformed with RxJS operators. It runs in an injection context, so it can call inject() (at the top, not inside async callbacks). Register it with provideHttpClient(withInterceptors([...])).
Key details:
req.clone({ setHeaders: {...} }) to change one.HttpContext tokens pass per-request flags, such as "skip auth for this call".Class-based interceptors (implementing HttpInterceptor, registered with the HTTP_INTERCEPTORS multi-provider) still work, but need withInterceptorsFromDi().
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
const router = inject(Router);
const authReq = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
return next(authReq).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) router.navigate(['/login']);
return throwError(() => err);
}),
);
};
// app.config.ts: provideHttpClient(withInterceptors([authInterceptor]))HttpClient request and responseHttpInterceptorFn registered via withInterceptorsreq.clone()Likely follow-up: How would you retry failed requests or refresh an expired token in an interceptor?
A subscription to a long-lived Observable keeps running after the component is destroyed: its callback still fires and holds a reference to the component, so it can't be garbage collected. Typical culprits are interval, fromEvent, subjects in root services, store selectors, router events and valueChanges. HTTP calls complete after one response, so they leak less, but cancelling them on destroy still avoids work for a dead component.
Options, most preferred first:
async pipe and toSignal() both unsubscribe automatically.takeUntilDestroyed() from @angular/core/rxjs-interop completes the stream when the component, directive or service is destroyed. It needs an injection context; in ngOnInit or later, pass it a DestroyRef.take(1) or first() when you only need one value.destroy$ Subject used with takeUntil(this.destroy$) and completed in ngOnDestroy, or keeping the Subscription and calling unsubscribe().Put takeUntil or takeUntilDestroyed last in the pipe, so no later operator keeps an inner subscription alive.
export class Dashboard implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
constructor() {
interval(5000).pipe(takeUntilDestroyed()).subscribe(() => this.refresh());
}
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) // not an injection context: pass DestroyRef
.subscribe((params) => this.load(params.get('id')));
}
}async pipe or toSignal(): automatic cleanuptakeUntilDestroyed() needs an injection context or DestroyReftakeUntil(destroy$) last in the pipeLikely follow-up: Do you need to unsubscribe from HttpClient calls?
async pipe do, and why is it preferred over subscribing manually?easyThe async pipe subscribes to an Observable (or a Promise) from the template and returns its latest value. It:
OnPush components.null until the first value arrives.It's preferred because there's less boilerplate, no manual subscription management, and the data stays a declarative stream instead of being copied into mutable fields.
The main pitfall: each | async creates its own subscription. Writing user$ | async three times against a cold HTTP Observable sends three requests. Subscribe once with @if (user$ | async; as user) or @let user = user$ | async;, or share the stream with shareReplay. In signal-based code, toSignal() does the same job from the class.
markForCheck(), so it works with OnPush| async is a separate subscription: subscribe onceThe CLI build uses main.ts as the entry point, and index.html contains the root element, such as <app-root>.
In a standalone app, main.ts calls bootstrapApplication(App, appConfig). Angular then:
appConfig (provideRouter, provideHttpClient and so on); providedIn: 'root' services are created on demand later,provideAppInitializer() and waits for any async ones,In NgModule apps, main.ts calls platformBrowser().bootstrapModule(AppModule) (older code uses platformBrowserDynamic()), and AppModule lists the root component in its bootstrap array.
With SSR, the server renders the same app to HTML first, and the browser hydrates that markup instead of rendering from scratch.
main.ts calls bootstrapApplication(App, appConfig)appConfig providersbootstrapModule(AppModule)@if, @for, @switch), and how does it differ from *ngIf and *ngFor?easyBuilt-in control flow is template syntax for conditionals and loops that replaces the structural directives:
@if (cond) { } @else if (other) { } @else { }, with as to alias the value, e.g. @if (user$ | async; as user).@for (item of items; track item.id) { } @empty { }, with the implicit variables $index, $count, $first, $last, $even and $odd.@switch (status) { @case ('active') { } @default { } }.How it differs from *ngIf, *ngFor and ngSwitch:
track is required in @for, which forces you to think about item identity; the old trackBy was optional and often forgotten.@empty and @else if are built in, and type narrowing works better inside @if.<ng-template> or <ng-container> wrappers are needed.NgIf, NgFor and NgSwitch are deprecated, and the CLI can migrate templates with ng generate @angular/core:control-flow.
@if (loading()) {
<app-spinner />
} @else {
@for (user of users(); track user.id; let i = $index) {
<p>{{ i + 1 }}. {{ user.name }}</p>
} @empty {
<p>No users found.</p>
}
}@if, @for, @switch are built into the template syntaxtrack is required in @for; @empty is built inNgIf/NgFor/NgSwitch are deprecated; CLI migration existsLikely follow-up: What should you pass to track, and what happens if you track by $index?
input(), output() and model(), and how do they compare with @Input() and @Output()?easyThey're the signal-based way to declare a component's public API.
input<T>(defaultValue) and input.required<T>() return a read-only InputSignal. You read it as this.name() in the class and name() in the template, and derive from it with computed() instead of ngOnChanges. Options include alias and transform, e.g. transform: booleanAttribute.output<T>() returns an OutputEmitterRef: call this.rated.emit(value), and the parent listens with (rated)="...". Outputs don't bubble like DOM events.model<T>() is a writable input signal for two-way binding. When the component calls set() or update(), Angular emits a matching <name>Change event, so the parent can write [(value)]="score".The classic equivalents are @Input() name!: string, with ngOnChanges or a setter to react to changes, and @Output() rated = new EventEmitter<number>(). The new APIs add compile-time checking of required inputs, better type inference and built-in reactivity through computed and effect, and they fit naturally with OnPush and zoneless change detection.
@Component({
selector: 'app-rating',
template: `
@for (star of stars; track star) {
<button [disabled]="disabled()" (click)="value.set(star); rated.emit(star)">★</button>
}
`,
})
export class Rating {
readonly value = model(0); // parent: [(value)]="score"
readonly disabled = input(false, { transform: booleanAttribute });
readonly rated = output<number>();
protected readonly stars = [1, 2, 3, 4, 5];
}input() returns a read-only signal; input.required() for mandatoryoutput() emits events with .emit(); no bubblingmodel() is a writable input enabling [(x)]ngOnChanges with computed() on inputsLikely follow-up: What does an input transform do?
<ng-content> work?midContent projection lets a parent pass markup into a child component, much like children in React. The child's template marks where it goes with <ng-content>.
<ng-content /> renders everything the parent put between the child's tags.<ng-content select="[card-title]" /> takes only matching content (a CSS selector on element, attribute or class), and a plain <ng-content /> catches the rest. ngProjectAs lets content match a different selector.<ng-content> renders when nothing is projected into that slot.A key detail: projected content is created and owned by the parent. It's instantiated even if the child hides the <ng-content>, so wrapping it in @if doesn't prevent creation, and the same content can't be projected twice. For conditional, lazy or repeated rendering, the parent passes an <ng-template> and the child renders it with ngTemplateOutlet.
The child reaches projected components with contentChild()/contentChildren() (classic @ContentChild), ready by ngAfterContentInit.
@Component({
selector: 'app-card',
template: `
<header><ng-content select="[card-title]" /></header>
<section><ng-content /></section>
<footer><ng-content select="app-card-actions">No actions</ng-content></footer>
`,
})
export class Card {}
// <app-card>
// <h2 card-title>Invoice #42</h2>
// <p>Due next week.</p>
// </app-card><ng-content> is a slot for markup passed by the parentselect creates named slots; default slot catches the restng-template + ngTemplateOutlet for conditional or repeated contentLikely follow-up: How do you access a projected component from the child?
<ng-template>, <ng-container> and <ng-content>?mid<ng-template> defines a template fragment that is not rendered on its own. Something has to instantiate it: a structural directive, ngTemplateOutlet, the else of *ngIf, or ViewContainerRef.createEmbeddedView(). It can declare context variables with let-, so one fragment can render with different data, and a template reference such as #row gives you its TemplateRef.<ng-container> is a grouping element that renders no DOM node. Use it to apply a structural directive without a wrapper <div>, to nest structural directives (only one is allowed per element), or as the host for *ngTemplateOutlet.<ng-content> is a projection slot in a child component's template, where the parent's markup is inserted.A common pattern combines them: a reusable list or table component accepts a TemplateRef (as an input, or through contentChild(TemplateRef)) so the parent decides how each item looks, and the component renders it with ngTemplateOutlet plus a context object.
<ng-template #userRow let-user let-i="index">
<li>{{ i + 1 }}. {{ user.name }}</li>
</ng-template>
<ul>
@for (u of users(); track u.id; let i = $index) {
<ng-container *ngTemplateOutlet="userRow; context: { $implicit: u, index: i }" />
}
</ul>ng-template: a fragment rendered only when instantiatedlet- variables receive values from the context objectng-container: grouping without an extra DOM elementng-content: slot for projected parent markupViewChild and ContentChild, and when are their results available?midBoth are queries that give a component a reference to something rendered in its tree. They differ in where they look:
viewChild(), viewChildren(), classic @ViewChild/@ViewChildren) search the component's own template.contentChild(), contentChildren(), classic @ContentChild/@ContentChildren) search the projected content the parent passed in through <ng-content>.You can query by template reference name ('box'), by component or directive class, or by token, and pick what you get back with read (ElementRef, TemplateRef, ViewContainerRef).
Timing: with decorators, view results are ready in ngAfterViewInit and content results in ngAfterContentInit, or already in ngOnInit with static: true if the element isn't inside a conditional block. The plural forms return a QueryList with a changes Observable. Signal queries return signals that update automatically, so you read them in computed(), effect() or afterNextRender() without worrying about hook timing, and viewChild.required() removes the undefined case.
@Component({
selector: 'app-search',
template: `<input #box /><ng-content />`,
})
export class Search {
readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
readonly tabs = contentChildren(Tab); // projected <app-tab> components
constructor() {
afterNextRender(() => this.box().nativeElement.focus());
}
}ngAfterViewInit / ngAfterContentInitviewChild()) update automaticallyread chooses ElementRef, TemplateRef or ViewContainerRefAngular templates aren't run as HTML. The Angular compiler turns each template into JavaScript instructions that create and update the DOM. The question is when that happens.
@angular/compiler) ships with the app, and templates are compiled as the app starts.AOT advantages:
strictTemplates, type errors in templates fail the build instead of the user's browser.Since the Ivy compiler became standard, AOT is the default for both ng build and ng serve, so JIT is mostly a legacy concern today.
Create a class with @Directive and an attribute selector such as [appHighlight]. Like components, directives are standalone by default, so a component adds it to imports to use it.
appHighlight="lightblue".host metadata, which angular.dev recommends over the classic @HostListener and @HostBinding decorators.ElementRef and use Renderer2 rather than manipulating nativeElement yourself; it's safer with SSR.A structural directive is built differently: it injects TemplateRef and ViewContainerRef, and calls createEmbeddedView() or clear() to add or remove the template, for example an *appHasRole="'admin'" directive. Directives can also be composed onto components with hostDirectives.
@Directive({
selector: '[appHighlight]',
host: {
'(mouseenter)': 'hovered.set(true)',
'(mouseleave)': 'hovered.set(false)',
'[style.backgroundColor]': 'background()',
},
})
export class Highlight {
readonly color = input('', { alias: 'appHighlight' });
protected readonly hovered = signal(false);
protected readonly background = computed(() => (this.hovered() ? this.color() || 'yellow' : null));
}
// <p appHighlight="lightblue">Hover me</p>@Directive with an attribute selector like [appHighlight]host bindings and listeners instead of direct DOM accessTemplateRef + ViewContainerRefLikely follow-up: What are hostDirectives used for?
Operators are pure functions chained with pipe(). Each returns a new Observable and leaves the source untouched. The everyday set:
map changes each value; scan accumulates state, like reduce over time.filter drops values; take(n) and first() complete after n values or the first match; distinctUntilChanged suppresses consecutive duplicates.debounceTime(ms) emits only after the source has been quiet for that long; throttleTime limits the rate.tap for logging or setting a loading flag without changing the value; finalize runs on complete, error or unsubscribe.switchMap, mergeMap, concatMap, exhaustMap map values to inner Observables such as HTTP calls.combineLatest, forkJoin, merge, withLatestFrom, startWith.catchError to recover, retry to resubscribe.shareReplay to multicast and cache.The typeahead search is the classic example that uses most of them together.
results$ = this.search.valueChanges.pipe(
map((q) => q.trim()),
filter((q) => q.length >= 2),
debounceTime(300),
distinctUntilChanged(),
tap(() => this.loading.set(true)),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
tap(() => this.loading.set(false)),
);pipe()map, filter, tap for transform, filter, side effectsdebounceTime + distinctUntilChanged for user inputswitchMap family for HTTP; catchError/retry for errorsLikely follow-up: What is the difference between debounceTime and throttleTime?
The difference is where the producer lives.
subscribe, so every subscriber gets its own, independent execution from the beginning. HttpClient requests, of, timer, interval and defer are cold.Why it matters:
http.get(), for example two | async pipes, sends two requests.To turn a cold Observable into a shared (multicast) one, use share(), where late subscribers only get future values, or shareReplay({ bufferSize: 1, refCount: true }), which also replays the latest value to late subscribers. That's the usual way to cache an HTTP result that several components read. A BehaviorSubject or a signal in a service is the other common way to share state.
share() / shareReplay() multicast a cold sourceforkJoin and combineLatest?midBoth combine several Observables into one, but they emit at different times.
forkJoin waits for every source to complete, then emits once with the last value of each (as an array or an object) and completes. It's the RxJS version of Promise.all, ideal for parallel HTTP calls. If any source errors, it errors; if any source completes without emitting, it completes without emitting anything. Don't use it with streams that never complete, such as valueChanges.combineLatest emits every time any source emits, once each source has emitted at least once, with the latest value from each. It's for derived state from long-lived streams, such as data plus filters. Seed slow sources with startWith if you need an early first emission.Related operators: zip pairs values by index, withLatestFrom samples other streams only when the main one emits, and merge interleaves values without combining them. In signal-based code, computed() often replaces combineLatest for synchronous state.
forkJoin: one emission after all sources completeforkJoin is Promise.all for Observables: parallel HTTPcombineLatest: emits on every change once all have emittedforkJoin never emits for never-completing streamsLikely follow-up: When would you use withLatestFrom instead of combineLatest?
A synchronous validator is a ValidatorFn: a function that takes an AbstractControl and returns null when valid, or an errors object such as { forbiddenValue: true }. Wrap it in a factory function when it needs parameters. For cross-field rules like "passwords match", put the validator on the FormGroup and read the child controls.
An async validator (AsyncValidatorFn) returns an Observable or Promise of ValidationErrors | null, typically from an HTTP call such as "is this username taken?". Details worth knowing:
PENDING.first().updateOn: 'blur', or debounce inside the validator, to avoid a request per keystroke.Show errors with control.hasError('taken') or control.errors?.['taken']. In template-driven forms, the same logic is wrapped in a directive that provides NG_VALIDATORS or NG_ASYNC_VALIDATORS.
export const forbiddenValue = (bad: string): ValidatorFn => (control) =>
control.value === bad ? { forbiddenValue: { bad } } : null;
export const passwordsMatch: ValidatorFn = (group) =>
group.get('password')?.value === group.get('confirm')?.value ? null : { mismatch: true };
export const usernameAvailable = (api: UserApi): AsyncValidatorFn => (control) =>
api.isTaken(control.value).pipe(map((taken) => (taken ? { taken: true } : null)));
username = new FormControl('', {
validators: [Validators.required, forbiddenValue('admin')],
asyncValidators: [usernameAvailable(inject(UserApi))],
updateOn: 'blur',
});ValidatorFn returns null or an errors objectFormGroupPENDINGupdateOn: 'blur'useClass, useValue, useFactory, useExisting), and when do you need an InjectionToken?hardA provider tells an injector how to produce the value for a token:
useClass: instantiate a class, possibly a different one than the token, e.g. { provide: Logger, useClass: ConsoleLogger }. A bare providers: [Logger] is shorthand for this.useValue: supply a ready-made value, such as a config object or a test double.useFactory: call a function when the token is first requested. The factory can call inject() (classic code lists its dependencies in deps), which makes it right for values computed from the environment.useExisting: an alias that points one token at another, so both return the same instance. useClass here would create a second one.multi: true: several providers add to one token, and injecting it returns an array, as with NG_VALIDATORS.An InjectionToken is needed for anything that isn't a class, because TypeScript interfaces and types disappear at runtime. new InjectionToken<AppConfig>('APP_CONFIG') gives you a typed, unique token; the string is only a debug label. A token can also carry a tree-shakable default with { providedIn: 'root', factory: () => ... }.
export const API_URL = new InjectionToken<string>('API_URL', {
providedIn: 'root',
factory: () => '/api', // default when nobody provides it
});
providers: [
{ provide: Logger, useClass: environment.production ? RemoteLogger : ConsoleLogger },
{ provide: API_URL, useValue: 'https://api.example.com' },
{ provide: STORAGE, useFactory: () => (isPlatformBrowser(inject(PLATFORM_ID)) ? localStorage : memoryStorage) },
{ provide: AuditLogger, useExisting: Logger },
]
const apiUrl = inject(API_URL); // typed as stringuseClass instantiates, useValue supplies a valueuseFactory computes the value and can call inject()useExisting aliases the same instanceInjectionToken for non-class values like configmulti: true collects several providers into an arrayAngular has two injector trees:
providers (or viewProviders) gets one on its host element.providedIn: 'root' plus the providers passed to bootstrapApplication), child injectors for routes with providers and lazy-loaded routes, then the platform injector, and finally the NullInjector, which throws.When a component injects a token, Angular walks up the element injectors from the requesting element, then up the environment injectors. The first provider found wins, and that injector owns the instance.
Consequences:
viewProviders hides the service from projected content.optional returns null instead of throwing, self looks only locally, skipSelf starts at the parent, and host stops at the host component.NullInjectoroptional, self, skipSelf, hostLikely follow-up: What is the difference between providers and viewProviders?
inject() function, how does it differ from constructor injection, and what is an injection context?hardinject(Token) resolves a dependency from the current injector, exactly like a constructor parameter, but as a function call. Both go through the same injectors, so the result is the same; the differences are about where you can use it.
inject() only works inside an injection context:
useFactory or an InjectionToken factory,runInInjectionContext(injector, fn).Calling it anywhere else, such as in a click handler, ngOnInit or a setTimeout, throws NG0203. Inject in a field and use the value later.
Why it's now preferred:
super().inject(Logger, { optional: true }) has the type Logger | null.inject() internally.runInInjectionContext for code outside those contextsChangeDetectorRef, and what is the difference between markForCheck() and detectChanges()?hardChangeDetectorRef gives a component manual control over its own change detection. Its main methods:
markForCheck() marks this view and all its ancestors dirty, so they're checked in the next pass. It doesn't run a check itself; in a zoneless app it also schedules that next pass. It's the fix when an OnPush component assigns a plain field in a callback Angular doesn't know about. The async pipe calls it for you.detectChanges() checks this view and its children synchronously, right now. Use it with detach(), or in tests. Reaching for it to silence NG0100 errors is usually a sign of broken data flow.detach() removes the view from the tree so passes skip it; reattach() puts it back. That suits widgets that refresh on their own schedule, like a high-frequency ticker.checkNoChanges() is the dev-mode verification check.In modern code you rarely need any of these: signals, the async pipe and toSignal() notify Angular automatically. Turning that plain field into a signal removes the need for markForCheck().
markForCheck(): marks view and ancestors for the next passdetectChanges(): checks this subtree synchronously nowdetach()/reattach() take a view out of normal checkingasync pipe make manual calls rare[( )] syntax?midThe "banana in a box" syntax is pure sugar. [(size)]="fontSize" compiles to a property binding plus an event binding:
[size]="fontSize" (sizeChange)="fontSize = $event"
So any component with an input x and an output named xChange supports two-way binding. The data flow is still one-way in each direction: the value goes down through the input, and changes come back up through the event.
model() declares both at once. size = model(16) is a writable signal; calling this.size.set(20) updates it locally and emits sizeChange. The parent can bind a plain field or a writable signal, passing the signal itself without calling it: [(size)]="fontSize".@Input() size plus @Output() sizeChange = new EventEmitter<number>(), emitting manually.[(ngModel)] follows the same pattern: [ngModel] plus (ngModelChange). Splitting it is handy when you want to transform the value, e.g. (ngModelChange)="name = $event.trim()".
[(x)] is [x] plus (xChange)x with an output xChange supports itmodel() declares the input and Change output togetherA resolver loads data before a route activates, so the component renders with its data already available. In modern Angular it's a function of type ResolveFn<T> that can call inject() and returns a value, a Promise or an Observable. You register it under the route's resolve key.
How it behaves:
RedirectCommand to a "not found" page.ActivatedRoute.data, or directly as an input with withComponentInputBinding().The trade-off is perceived performance: nothing on screen changes until the data arrives, so the app can feel frozen on slow connections. Show a global progress bar from router events, or load the data in the component instead (with httpResource() or an Observable) and render a skeleton. Resolvers fit best when the page is meaningless without its data.
ResolveFn with inject(); runs after guardsActivatedRoute.data or component inputs@defer blocks, and how do they work?mid@defer lazy loads a section of a template. The components, directives and pipes used only inside the block are split into a separate chunk and loaded when a trigger fires, which keeps them out of the initial bundle.
Triggers:
on idle (the default): when the browser is idle.on viewport, on interaction, on hover: based on the placeholder or a referenced element.on immediate, on timer(2s), or when condition for a custom expression.prefetch on ... downloads the code earlier than it's rendered.Companion blocks: @placeholder shows before loading starts (with an optional minimum display time), @loading shows while the chunk downloads (after and minimum prevent flicker), and @error shows if loading fails.
Gotchas: dependencies must be standalone, and if they're referenced anywhere else in the same file (another part of the template, or a viewChild query) they're bundled eagerly. During SSR, the server renders the @placeholder. With incremental hydration, hydrate on triggers let the server render the real content and hydrate it later.
@defer (on viewport; prefetch on idle) {
<app-comments [postId]="postId()" />
} @placeholder (minimum 300ms) {
<p>Comments</p>
} @loading (after 100ms; minimum 500ms) {
<app-spinner />
} @error {
<p>Could not load comments.</p>
}idle (default), viewport, interaction, hover, timer, when@placeholder, @loading, @error sub-blocksLikely follow-up: When would you pick @defer over lazy loading a route?
View encapsulation controls whether a component's styles stay inside that component. It's set with the encapsulation option:
ViewEncapsulation.Emulated (the default): Angular adds generated attributes to the component's elements (like _ngcontent-... and _nghost-... on the host) and rewrites its CSS selectors to include them. Styles don't leak out to other components, but global styles still apply inside.ViewEncapsulation.ShadowDom: uses the browser's native Shadow DOM, so isolation works in both directions. Only inherited properties and CSS custom properties cross the boundary.ViewEncapsulation.None: no scoping; the styles become global once the component loads.Related selectors: :host styles the component's own host element, and :host-context(.dark) applies styles based on an ancestor. ::ng-deep pushes a rule into child components; it's deprecated but still supported. For theming children, CSS custom properties are the cleaner option, since they pass through every encapsulation mode.
:host styles the host; ::ng-deep is deprecatedA template reference variable is declared with #name on an element, and gives you a handle you can use elsewhere in the same template. What it refers to depends on where it's placed:
HTMLInputElement.exportAs name: that directive instance, e.g. #f="ngForm" or #m="matMenu".<ng-template>: its TemplateRef.Common uses are reading an input's value without binding it to a field, letting one element control another, and naming elements for viewChild('name').
Scope: a variable is visible throughout its template, but one declared inside an @if or @for block (or an <ng-template>) isn't visible outside that block. For values computed in the template, @let declares a local variable instead.
<input #box placeholder="New todo" (keyup.enter)="add(box.value)" />
<button (click)="add(box.value)">Add</button>
<app-video-player #player src="intro.mp4" />
<button (click)="player.play()">Play</button>
<form #f="ngForm" (ngSubmit)="save(f.value)">
<input name="email" ngModel required />
<button [disabled]="f.invalid">Save</button>
</form>#name in the template#f="ngForm" binds a directive via exportAsng-template it is a TemplateRefFirst measure: the Angular DevTools profiler shows which components are checked and how long each pass takes, Lighthouse covers load metrics, and bundle analysis shows what's in each chunk. Then work on two fronts.
Load time
loadComponent/loadChildren, and heavy template sections with @defer.angular.json, and keep large libraries out of the main chunk.NgOptimizedImage (ngSrc, plus priority on the LCP image) for sizing and lazy loading of images.Runtime
track expression in @for (trackBy for *ngFor), so DOM nodes are reused instead of recreated.computed() instead of method calls in templates, which re-run on every check.cdk-virtual-scroll-viewport) for long lists.switchMap, and clean up subscriptions.NgZone.runOutsideAngular().@defer heavy sectionstrack in @for; no method calls in templatesNgOptimizedImage, SSRLikely follow-up: What does the track expression in @for actually do?
Errors are handled at several levels:
HttpClient errors with an HttpErrorResponse. status holds the HTTP status code, and 0 usually means a network or CORS failure. Use catchError to return a fallback (of([])) or rethrow a friendlier error with throwError(() => ...). Inside a switchMap, catch on the inner Observable so the outer stream survives.retry({ count: 2, delay: 1000 }) resubscribes, which re-sends the request. Use it for transient failures and idempotent requests, not blindly for writes.401 or showing a toast.ErrorHandler, which logs to the console by default. Provide your own implementation to report to a monitoring service. provideBrowserGlobalErrorListeners() also forwards the window's error and unhandledrejection events to it.With signals, toSignal() rethrows the Observable's error when the signal is read, and resource() exposes an error() signal and an 'error' status for the template to render.
catchError with a fallback or a rethrow; catch inside inner streamsretry with count and delay for transient failuresErrorHandler for global reportingThey solve different problems, and they interoperate.
Signals model state: a value that always exists and changes over time. Reads are synchronous, computed() derivations are memoized and glitch-free, there's no subscription to manage, and templates that read them get fine-grained change detection. Use them for component state, derived values, inputs and shared state in services.
Observables model events and async streams: things that happen over time, possibly with no current value. They shine at time and concurrency logic: debouncing, cancellation with switchMap, retries, combining several async sources, WebSockets. HttpClient, the router and reactive forms all expose Observables.
A common split is RxJS at the edges for async orchestration and signals for the state the template reads. Bridge the two with toSignal() and toObservable(), and use resource() or httpResource() for signal-driven data fetching.
Signals don't replace all of RxJS: a signal has no notion of completion or error events, and if you set it twice synchronously, consumers only see the last value.
toSignal() / toObservable()Likely follow-up: How does toSignal() handle errors from the Observable?
Zone.js monkey-patches the browser's async APIs (addEventListener, setTimeout, Promise, XHR and more), so Angular hears about every task that runs. When a task finishes, NgZone triggers ApplicationRef.tick(), a change detection pass from the root. That's why mutating any field in any callback "just works".
The costs: extra bundle size and startup work, a pass after every task even if nothing changed (a mousemove handler, a polling timer), noisier stack traces, and native async/await can't be patched, so the CLI has to downlevel it.
Zoneless change detection drops Zone.js. Angular schedules a pass only when it's notified: a signal read in a template changes, a template or host listener runs, markForCheck() is called (the async pipe does this), ComponentRef.setInput() runs, or a view is attached or removed. Recent Angular versions are zoneless by default; older apps opt in with provideZonelessChangeDetection() and remove zone.js from the polyfills.
Migration checklist: keep async state in signals or the async pipe, make components OnPush-compatible, replace NgZone.onStable with afterNextRender(), and use PendingTasks so SSR waits for async work.
tick()async/await downlevelingmarkForCheck, setInput notifyprovideZonelessChangeDetection(); default in recent versionstoSignal() and toObservable() work, and what should you watch out for?hardBoth come from @angular/core/rxjs-interop and bridge the two reactive models.
toSignal(obs$):
injector;undefined until the first emission, unless you pass initialValue, or requireSync: true for sources that emit synchronously such as a BehaviorSubject;toObservable(sig):
effect, so emissions are asynchronous, and several synchronous set() calls collapse into a single emission of the latest value;The classic pattern is signal, then toObservable, then RxJS operators such as debounceTime and switchMap, then toSignal for the template. For "refetch when a signal changes", rxResource() or httpResource() express it more directly.
export class UserSearch {
private readonly api = inject(UserApi);
readonly query = signal('');
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
),
{ initialValue: [] },
);
}toSignal subscribes immediately and cleans up on destroyundefined until first value unless initialValue/requireSynctoSignal rethrows errors on readtoObservable emits asynchronously via an effectFormControl often typed as string | null?midFormControl, FormGroup and FormArray carry the type of their value, inferred from the initial values, so form.value, valueChanges and setValue() are all type-checked.
The details interviewers probe:
new FormControl('') is FormControl<string | null>, because reset() sets a control to null by default. Pass { nonNullable: true }, or build the form with NonNullableFormBuilder (fb.nonNullable), and reset() returns to the initial value, so the type is just string.form.value is a Partial<...> because disabled controls are left out of it. form.getRawValue() includes every control and has the full type.form.controls.email is fully typed, so a misspelled key is a compile error.FormRecord handles dynamic keys with a single value type, and FormArray<FormControl<string>> a typed list.UntypedFormControl and UntypedFormGroup are escape hatches for migrating older code.export class Signup {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
age: [18],
});
// form.controls.email: FormControl<string>
// form.value: Partial<{ email: string; age: number }>
// form.getRawValue(): { email: string; age: number }
clear() {
this.form.reset(); // back to '' and 18, never null
}
}reset() sets null by default, hence string | nullnonNullable / NonNullableFormBuilder remove nullvalue is Partial (disabled omitted); getRawValue() is completeFormRecord for dynamic keysControlValueAccessor, and how do you make a custom component work with formControlName and ngModel?hardControlValueAccessor is the bridge between Angular forms and a form control's UI. Built-in accessors connect <input>, <select> and checkboxes. For a custom widget, such as a star rating or a date picker, you implement it yourself:
writeValue(value): the form pushes a value into the component (initial value, setValue, reset).registerOnChange(fn): save fn and call it when the user changes the value, to update the form model.registerOnTouched(fn): save it and call it on blur or interaction, so touched and updateOn: 'blur' work.setDisabledState(isDisabled) (optional): react to control.disable().Register the component as an accessor with an NG_VALUE_ACCESSOR provider, using multi: true and useExisting. forwardRef is needed because the class is referenced inside its own decorator, before it's defined.
After that, the component works with formControlName, [formControl] and [(ngModel)], and validators, touched state and CSS status classes all come for free. To bundle a built-in validation rule with the component, also provide NG_VALIDATORS and implement Validator.
@Component({
selector: 'app-star-rating', templateUrl: './star-rating.html',
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true }],
})
export class StarRating implements ControlValueAccessor {
protected readonly value = signal(0);
protected readonly disabled = signal(false);
private onChange = (_: number) => {}; private onTouched = () => {};
writeValue(v: number | null) { this.value.set(v ?? 0); }
registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(d: boolean) { this.disabled.set(d); }
protected select(n: number) { this.value.set(n); this.onChange(n); this.onTouched(); }
}writeValue, registerOnChange, registerOnTouched, setDisabledStateNG_VALUE_ACCESSOR with useExisting and multi: trueforwardRef because the class is not yet definedformControlName and ngModelI pick the lightest tool that fits:
signal, expose read-only state through asReadonly() and computed() selectors, and make methods the only way to change it. Scope it with providedIn: 'root' or component and route providers. Before signals, the same pattern used a private BehaviorSubject.@ngrx/signals): a lighter, signal-based store built from features like withState, withComputed and withMethods.I start with services and signals, and move to NgRx when many features share complex state, changes need to be traceable, or a large team benefits from enforced conventions. Server data (caching, refetching) often fits httpResource() or a query library better than a global store.
@Injectable({ providedIn: 'root' })
export class TodoStore {
private readonly todosState = signal<Todo[]>([]);
readonly todos = this.todosState.asReadonly();
readonly remaining = computed(() => this.todos().filter((t) => !t.done).length);
add(title: string) {
this.todosState.update((list) => [...list, { id: crypto.randomUUID(), title, done: false }]);
}
toggle(id: string) {
this.todosState.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
}computed()SSR renders the app to HTML on the server (Node) for each request, so users see content before the JavaScript loads, which helps first paint, LCP and SEO. Prerendering (SSG) does the same at build time. You add it with ng new --ssr or ng add @angular/ssr, and a server routes config picks a render mode per route.
Hydration (provideClientHydration()) lets the browser reuse the server-rendered DOM instead of re-creating it, avoiding flicker and wasted work. Useful features:
withEventReplay() records clicks made before hydration finishes and replays them.withIncrementalHydration() with @defer (hydrate on viewport) keeps parts of the page dehydrated until needed.HttpClient's transfer cache reuses GET responses fetched on the server, so the client doesn't request them again.Watch out for:
window, localStorage) don't exist on the server. Use afterNextRender() or isPlatformBrowser().ngSkipHydration.@defer (hydrate on ...)afterNextRender() or behind isPlatformBrowserDomSanitizer?midAngular treats every bound value as untrusted by default:
{{ }} and text bindings are escaped, so <script> shows up as text.[innerHTML] is sanitized: scripts, event-handler attributes and other dangerous markup are stripped, with a dev-mode warning.[href] and [src] are sanitized, so a javascript: URL is neutralized.<iframe> src, can't be bound to untrusted values at all; Angular throws.DomSanitizer has bypassSecurityTrustHtml, ...Url, ...ResourceUrl, ...Style and ...Script, which mark a value as trusted and skip sanitization. Use them only for content you fully control or have sanitized yourself, such as an embed URL you built from a video id, never for raw user input.
You lose this protection by going around Angular: writing to nativeElement.innerHTML, building templates from user input, or misusing the bypass methods. Add a CSP and Trusted Types for defense in depth. For CSRF, HttpClient reads an XSRF-TOKEN cookie and sends it as an X-XSRF-TOKEN header on mutating requests to relative URLs.
export class VideoEmbed {
private readonly sanitizer = inject(DomSanitizer);
readonly videoId = input.required<string>();
// We build the URL ourselves from a validated id, so trusting it is safe
readonly src = computed(() =>
this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube.com/embed/${encodeURIComponent(this.videoId())}`,
),
);
}
// <iframe [src]="src()"></iframe>[innerHTML] is sanitized; resource URLs need explicit trustDomSanitizer.bypassSecurityTrust* only for controlled contentHttpClientTestBed and component harnesses?midTestBed creates a small Angular environment for a test. You configure it like an app, with imports (the standalone component under test) and providers (fakes for its dependencies), then:
TestBed.createComponent(Comp) returns a ComponentFixture, with componentInstance, nativeElement and componentRef.setInput() for inputs.fixture.detectChanges(), or in zoneless tests await fixture.whenStable().TestBed.inject(Service), or even as plain classes.provideHttpClient() and provideHttpClientTesting(), then use HttpTestingController to expectOne() a request, flush() a response and verify() that nothing else was sent.Component harnesses (from @angular/cdk/testing) are page-object APIs for components, e.g. MatButtonHarness. Tests call await button.click() instead of querying DOM internals, so they keep working when a component's markup changes. Angular Material ships harnesses for its components, and you can write your own.
New CLI projects use Vitest as the default runner; older projects typically run Karma with Jasmine. End-to-end tests usually use Playwright or Cypress.
it('shows the user name', async () => {
TestBed.configureTestingModule({
imports: [UserCard],
providers: [{ provide: UserService, useValue: { current: signal({ name: 'Ada' }) } }],
});
const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('compact', true);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Ada');
});TestBed configures imports and fake providerscreateComponent returns a fixture; setInput for inputsdetectChanges() or whenStable() to renderHttpTestingController to expect and flush requestsThe Angular CLI (ng) scaffolds, builds, serves, tests and updates Angular projects, and applies code changes through schematics.
ng new my-app: create a workspace with a configured project.ng serve: start the dev server with rebuilds on save.ng generate (ng g): scaffold a component, service, directive, pipe, guard, interceptor and more, e.g. ng g c user-card.ng build: a production build by default, with AOT compilation, minification, tree-shaking, hashed file names and bundle budget checks.ng test: run unit tests.ng add <package>: install a library and run its setup schematic, e.g. ng add @angular/material or ng add @angular/ssr.ng update: upgrade Angular and run migrations that rewrite your code for breaking changes.Project configuration lives in angular.json: builders, build configurations (development, production), fileReplacements for environments, budgets and assets. New projects use the esbuild-based application builder with a Vite-powered dev server.
ng new, ng serve, ng generate, ng build, ng testng build is a production build: AOT, minified, tree-shakenng add installs and configures librariesng update upgrades and runs migrationsangular.json holds builders, configurations and budgetslinkedSignal, and how is it different from computed?hardlinkedSignal creates a writable signal whose value is derived from other signals but can also be overridden locally.
computed(), it recomputes whenever the signals it depends on change.computed(), you can call set() and update() on it. A local value holds until the source changes again, and then the computation resets it.Typical cases are a selection that should reset when its list of options changes, and an editable draft of an input value that resets when a new record arrives.
There are two forms:
linkedSignal(() => this.options()[0]).{ source, computation }: the computation receives the new source value and previous ({ source, value }), so it can keep the user's choice when it's still valid.It replaces the anti-pattern of an effect() that copies one signal into another writable signal. If the value never needs a manual override, use computed().
export class ShippingPicker {
readonly options = input.required<ShippingOption[]>();
// Resets to the first option whenever options change; the user can still pick another
readonly selected = linkedSignal(() => this.options()[0]);
// Keeps the user's choice if it still exists in the new list
readonly kept = linkedSignal<ShippingOption[], ShippingOption>({
source: this.options,
computation: (opts, previous) => opts.find((o) => o.id === previous?.value.id) ?? opts[0],
});
}set() holds until the source changescomputed() is read-only; linkedSignal is overridableprevious lets you preserve a still-valid valueresource() and httpResource(), and what problem do they solve?hardThey bring async data loading into the signal model, so you don't have to hand-wire "when this signal changes, fetch, track loading, handle errors".
resource() takes a params function that reads signals and a loader that returns a Promise. Whenever the params change, it runs the loader again and aborts the previous run through the abortSignal it passes in. If params returns undefined, the loader doesn't run and the resource stays idle. It exposes signals and methods:
value(), hasValue(), error(), isLoading(),status(): 'idle', 'loading', 'reloading', 'resolved', 'error' or 'local' (after a local set()),reload(), set() and update().Variants: rxResource() from @angular/core/rxjs-interop takes an Observable-returning stream function, and httpResource() from @angular/common/http takes a reactive URL or request and goes through HttpClient, so interceptors and testing tools apply.
Pitfalls: value() throws while the resource is in the error state, so check hasValue() or error() in the template. Resources are for reading data; send writes (POST, PUT, DELETE) with HttpClient directly.
export class UserDetail {
readonly id = input.required<string>();
readonly user = httpResource<User>(() => `/api/users/${this.id()}`);
}
// Template:
// @if (user.isLoading()) { <app-spinner /> }
// @else if (user.error()) { <p>Could not load user.</p> }
// @else if (user.hasValue()) { <h2>{{ user.value().name }}</h2> }rxResource for Observables, httpResource via HttpClientvalue() throws in error state: check hasValue()ExpressionChangedAfterItHasBeenCheckedError (NG0100), and how do you fix it properly?hardIt's a development-mode check. After each change detection pass, Angular runs a second verification pass over the same bindings. If one now produces a different value than the one it just rendered, it throws NG0100, because the screen would disagree with the state. Production builds skip the check, so there the same bug silently shows stale values.
Common causes:
ngAfterViewInit or ngAfterViewChecked, after the view was already checked.Date.now() or Math.random().Proper fixes:
ngOnInit.computed() instead of assigning them in lifecycle hooks, or make the value a signal.Wrapping the assignment in setTimeout or calling detectChanges() hides the symptom without fixing the flow.
@Component({
selector: 'app-banner',
changeDetection: ChangeDetectionStrategy.Eager, // named Default in older versions
template: `<p>{{ message }}</p>`,
})
export class Banner implements AfterViewInit {
message = 'Loading';
ngAfterViewInit() {
this.message = 'Loaded'; // NG0100: the view was already checked with 'Loading'
}
}ngAfterViewInitcomputed(), signalssetTimeout or detectChanges() only hide itCommon cases are dialogs, dashboards built from configuration and plugin widgets. There are three main tools:
NgComponentOutlet, e.g. <ng-container *ngComponentOutlet="widget.component; inputs: widget.inputs" />. It's declarative, and the simplest option when a config object decides the component.ViewContainerRef.createComponent(Comp) inserts the component at a location, taken from inject(ViewContainerRef) or a query with { read: ViewContainerRef }. It returns a ComponentRef. Set inputs with ref.setInput('title', value), which also marks it for check, subscribe to outputs through ref.instance, and call ref.destroy() when you're done. Newer versions also accept bindings such as inputBinding() and outputBinding() at creation time.document.body: the standalone createComponent(Comp, { environmentInjector, hostElement }), then appRef.attachView(ref.hostView) so change detection reaches it.Combine these with a dynamic import() so the component's code loads only when needed. Older code went through ComponentFactoryResolver; since Ivy you pass the component class directly, and recent versions have removed that API.
@Component({
selector: 'app-dashboard',
template: `<ng-container #slot />`,
})
export class Dashboard {
private readonly slot = viewChild.required('slot', { read: ViewContainerRef });
async addChart(title: string) {
const { SalesChart } = await import('./sales-chart');
const ref = this.slot().createComponent(SalesChart);
ref.setInput('title', title);
ref.instance.closed.subscribe(() => ref.destroy());
}
}NgComponentOutlet for declarative dynamic renderingViewContainerRef.createComponent() returns a ComponentRefsetInput() for inputs; destroy() to clean upimport() for lazy loadingComponentFactoryResolver needed any moreAngular's built-in i18n uses @angular/localize (added with ng add @angular/localize) and works at build time:
i18n attribute, optionally with a meaning, a description and a custom id: i18n="site header|Welcome message@@homeWelcome". Attributes use i18n-<attr>, e.g. i18n-title.$localize tagged template.{count, plural, =0 {...} other {...}} and select.ng extract-i18n to generate a source translation file (XLIFF by default); translators return one file per locale.angular.json and build with localize. The CLI produces a separate, fully translated build per locale, so there's no runtime lookup cost.Locale-aware pipes (date, currency, number) follow LOCALE_ID.
The trade-off: switching language means loading a different build, usually served under a URL prefix like /fr/. For switching at runtime without a reload, teams use libraries such as Transloco or ngx-translate.
<h1 i18n="site header|Welcome message@@homeWelcome">Welcome to Stacktrick</h1>
<img [src]="logoUrl" i18n-alt alt="Stacktrick logo" />
<span i18n>
{count, plural, =0 {No new messages} =1 {One new message} other {{{count}} new messages}}
</span>i18n, attributes with i18n-attr$localize for strings in TypeScriptng extract-i18n produces translation filesNo questions match that filter.
Prefer multiple choice? All 32 Angular MCQs with answers →