Interview prep · Junior → Senior
Angular Interview Questions
The questions an Angular interviewer actually asks about the framework —
components and templates, directives and pipes, dependency injection,
change detection (the load-bearing one),
RxJS and Signals reactivity, forms, routing, HTTP, lifecycle and modern
standalone Angular, the AngularJS legacy distinction, and the classic gotchas —
each answered with a short example and a link to the source.
This page covers Angular the framework and assumes TypeScript fluency; for the
language beneath it see the
TypeScript interview questions.
Pair it with the
Angular version reference
and the open Angular roles on the jobs board.
Difficulty
OnPush, the RxJS operators, signals vs observables, the injector hierarchy, and the gotchas.
Showing 0 of 0 questions
No questions match that combination. .
1 · Fundamentals & architecture
What is Angular, and how is it different from AngularJS? Junior
Angular is a complete, opinionated front-end framework maintained by Google — not just a view library like React. Out of the box it ships routing, an HTTP client, a forms system, dependency injection, an RxJS-based reactivity layer, and a CLI, so the “which library do I pick for X” decisions are largely made for you. It is TypeScript-first: components, services, and templates are all typed. “Angular” means Angular 2 and later — a complete 2016 rewrite. AngularJS (1.x) is a different, now end-of-lifed framework with a different mental model ($scope, controllers, the digest cycle). They share a name and almost nothing else; see the AngularJS legacy section for the contrast.
// A modern (standalone) Angular component — TypeScript, decorator, typed template. import { Component, signal } from '@angular/core'; @Component({ selector: 'app-hello', template: `<button (click)="inc()">{{ count() }}</button>`, }) export class HelloComponent { count = signal(0); inc() { this.count.update(n => n + 1); } }
Why it's asked / follow-up: it separates candidates who know the framework's shape from those who've only touched a tutorial, and it surfaces the AngularJS confusion early. Follow-up: “framework or library?” — framework: it calls your code (inversion of control) and dictates the app structure. See the 2016 rewrite history for how Angular 2 broke from AngularJS.
Source: Angular — What is Angular?.
What are the core building blocks of an Angular app? Junior
Four pieces do most of the work. Components are the unit of UI — a TypeScript class plus a template and styles — and they compose into a tree rooted at the app's entry component. Templates are HTML extended with Angular's binding syntax and control flow. Directives attach behavior to elements (a component is itself a directive with a template). Services hold reusable logic and state and are delivered to components through dependency injection. Around those sit pipes (declarative value transforms in templates), the router, and the forms and HTTP modules.
// A service (injectable state/logic) consumed by a component via DI. @Injectable({ providedIn: 'root' }) export class CartService { items = signal<Item[]>([]); add(i: Item) { this.items.update(xs => [...xs, i]); } } @Component({ selector: 'app-cart', template: `{{ cart.items().length }}` }) export class CartComponent { cart = inject(CartService); // injected, not newed up }
Why it's asked / follow-up: it checks that you can name the moving parts and, crucially, know that services come through DI rather than being instantiated by hand. Follow-up: “where does state live?” — component-local state on the component; shared state in a service, increasingly held in signals.
Source: Angular — Essentials.
NgModules vs standalone components — what changed?
Mid
For most of Angular's history every component belonged to an NgModule — a container that declared components and wired up their dependencies via declarations and imports. It was boilerplate that newcomers found baffling. Standalone components remove the middle layer: a component sets standalone: true (the default since v19) and imports exactly what its own template needs, directly. There is no declarations array and usually no NgModule at all. Dependencies that used to be module-wide (the router, HttpClient) are now provided through provide* functions at bootstrap. Standalone was preview in v14, stable in v15, and the default in v19.
// Standalone: the component imports what its template uses, directly. @Component({ selector: 'app-page', imports: [RouterLink, UserCardComponent], // no NgModule needed template: `<a routerLink="/home">Home</a> <app-user-card/>`, }) export class PageComponent {}
Why it's asked / follow-up: it maps directly to whether you've worked with modern Angular or only the older module-heavy style. Follow-up: “do NgModules still exist?” — yes, they still work and large apps still have them, but new code is standalone-by-default and Angular provides a migration schematic. See the standalone-stable release and standalone-by-default.
How does an Angular app bootstrap, and what is Ivy? Mid
A modern app starts with bootstrapApplication(RootComponent, appConfig) in main.ts. The second argument is where app-wide providers live — provideRouter, provideHttpClient, and so on — replacing the old root NgModule's providers. Angular creates the root component, renders its template into the DOM, and hands control to change detection. Ivy is the compilation and rendering engine underneath all of this (default since Angular 9). It compiles each component into standalone instructions, which enables better tree-shaking, faster builds, and — importantly — is the foundation the later signals and standalone work was built on.
// main.ts import { bootstrapApplication } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; bootstrapApplication(AppComponent, { providers: [ provideRouter(routes), provideHttpClient() ], });
Why it's asked / follow-up: the bootstrap answer shows you know where app-wide config lives now; the Ivy mention shows depth. Follow-up: “why does Ivy matter to me day to day?” — smaller bundles and the enabling layer for standalone components, signals, and zoneless. See the Ivy migration history.
Source: Angular — bootstrapApplication.
2 · Components & templates
What does the @Component decorator do, and what's in its metadata?
Junior
@Component marks a class as an Angular component and attaches the metadata Angular needs to render it. The essentials: selector (the tag or attribute that triggers the component in a template), template or templateUrl (the view), and optionally styles/styleUrls, imports (for standalone components), providers, and changeDetection. Angular reads this metadata at compile time to generate the component's rendering instructions.
@Component({ selector: 'app-user', imports: [DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: `<h2>{{ name }}</h2><p>{{ joined | date }}</p>`, styles: `h2 { color: rebeccapurple; }`, }) export class UserComponent { name = 'Ada'; joined = new Date(); }
Why it's asked / follow-up: it confirms you understand a component is a class plus declarative metadata, not just a template. Follow-up: “attribute or element selector?” — element (app-user) for a real UI block; attribute ([appHighlight]) is the directive convention.
Source: Angular — Components.
What are the kinds of data binding — and what does [(ngModel)] really mean?
Junior
Angular has four binding forms, distinguished by their brackets. Interpolation {{ expr }} and property binding [prop]="expr" flow data into the view (component → DOM). Event binding (event)="handler()" flows out (DOM → component). Two-way binding [(x)]="expr" — the “banana in a box” — is just sugar for both at once: a [x] input plus an (xChange) output. So [(ngModel)]="name" expands to [ngModel]="name" (ngModelChange)="name = $event". There's no magic global two-way binding as in AngularJS; it's a naming convention over one-way pieces.
<!-- these two lines are equivalent --> <input [(ngModel)]="name"> <input [ngModel]="name" (ngModelChange)="name = $event"> <!-- property vs attribute: [disabled] binds the DOM property --> <button [disabled]="isBusy">Save</button>
Why it's asked / follow-up: the [(x)]-is-sugar-for-input-plus-output insight is the tell for real understanding. Follow-up: “how do you make your own two-way binding?” — expose an input x and an output named xChange (or use the model() signal, which wires both automatically).
Source: Angular — Two-way binding.
How do parent and child components communicate — @Input/@Output and the signal-based input()/output()?
Mid
A parent passes data down through inputs and receives events up through outputs. The classic form is the @Input() and @Output() decorators, where an output is an EventEmitter the child calls .emit() on. Modern Angular (v17+) adds the signal-based input() and output() functions: input() returns a readable signal (so the template and computed/effect react to it), input.required() enforces a value, and model() creates a two-way input/output pair. For anything beyond parent/child, use a shared service.
// Modern signal inputs/outputs (v17+) export class RatingComponent { value = input.required<number>(); // <app-rating [value]="3"/> changed = output<number>(); // (changed)="onChange($event)" double = computed(() => this.value() * 2); // reacts to input changes set(n: number) { this.changed.emit(n); } }
Why it's asked / follow-up: input/output is the everyday component contract, and knowing the signal form signals current-Angular fluency. Follow-up: “when NOT to use inputs?” — for sibling or deep cross-tree communication, a shared service (with a signal or Subject) beats threading inputs through intermediate components. See the signal-inputs release.
Source: Angular — Component inputs.
What is content projection (ng-content), and how do @ViewChild vs @ContentChild differ?
Senior
Content projection lets a component render markup the parent supplied, via <ng-content> — the same idea as slots. You can project everything, or use a select attribute for multi-slot projection. The distinction interviewers probe: @ViewChild queries elements in the component's own template (its view), whereas @ContentChild queries the projected content that came from the parent. Timing follows the split: view queries resolve by ngAfterViewInit, content queries by ngAfterContentInit. (Modern Angular also offers the signal-based viewChild()/contentChild() query functions.)
// <app-card>projected</app-card> — 'projected' lands in ng-content @Component({ selector: 'app-card', template: `<section><ng-content/></section>` }) export class CardComponent { header = contentChild<ElementRef>('hdr'); // projected-in content body = viewChild<ElementRef>('body'); // this component's own view }
Why it's asked / follow-up: the view-vs-content mental model (and its two ngAfter* timings) is a classic senior discriminator. Follow-up: “why does a @ViewChild read undefined in ngOnInit?” — the view isn't initialized yet; read it in ngAfterViewInit (or use a signal query, which updates when it resolves).
Source: Angular — Content projection.
What is view encapsulation (Emulated / ShadowDom / None)? Mid
View encapsulation controls how a component's styles are scoped. Emulated (the default) scopes styles to the component by rewriting your selectors and stamping unique attributes onto its elements — it emulates the Shadow DOM's scoping without using it, so a component's CSS doesn't leak out and outside CSS doesn't (mostly) leak in. ShadowDom uses the browser's real Shadow DOM for true isolation (with its stricter rules about global styles). None disables scoping entirely — the component's styles become global. Default to Emulated; reach for None only deliberately.
@Component({ selector: 'app-badge', encapsulation: ViewEncapsulation.Emulated, // the default styles: `.badge { color: teal; }`, // scoped to this component only template: `<span class="badge"><ng-content/></span>`, }) export class BadgeComponent {}
Why it's asked / follow-up: it's the answer to “why don't my component styles leak” and “why did they suddenly leak” (someone set None). Follow-up: “how do you style a child's internals from a parent?” — prefer CSS custom properties (which pierce encapsulation); ::ng-deep exists but is deprecated and leaks, so avoid it.
Source: Angular — Styling components.
3 · Directives & pipes
Structural directives (*ngIf/*ngFor) vs the new built-in control flow (@if/@for)?
Junior
Structural directives change the DOM structure by adding or removing elements. The classics — *ngIf, *ngFor, *ngSwitch — use the * syntax, which is sugar for wrapping the element in an <ng-template>. Since Angular 17 there's built-in control flow — @if, @for, @switch — a block syntax baked into the template compiler. It's faster (no directive instantiation), has cleaner ergonomics (@else, a required track on @for, an @empty block), and is the recommended default for new code. The old directives still work; a schematic migrates them.
<!-- new built-in control flow (v17+) --> @if (user(); as u) { <p>{{ u.name }}</p> } @else { <p>Guest</p> } @for (item of items(); track item.id) { <li>{{ item.label }}</li> } @empty { <li>None</li> }
Why it's asked / follow-up: it dates your Angular knowledge and checks that you know @for's track is mandatory. Follow-up: “what did * desugar to?” — an <ng-template> the directive stamps in or out. See the control-flow release.
Source: Angular — Control flow.
What's an attribute directive, and how do you write a custom one? Mid
An attribute directive changes the appearance or behavior of an existing element without adding or removing it from the DOM (that's the structural directives' job). You write one as a class with @Directive and an attribute selector, injecting ElementRef or using @HostBinding/@HostListener to bind host properties and events. A component, notably, is a directive — one with a template. Custom attribute directives are the idiomatic way to package cross-cutting element behavior (a highlight, an autofocus, a permission-gated disable).
@Directive({ selector: '[appHighlight]' }) export class HighlightDirective { color = input('yellow', { alias: 'appHighlight' }); @HostBinding('style.background') get bg() { return this.hovered ? this.color() : ''; } hovered = false; @HostListener('mouseenter') on() { this.hovered = true; } @HostListener('mouseleave') off() { this.hovered = false; } } // usage: <p [appHighlight]="'lime'">hover me</p>
Why it's asked / follow-up: it checks you know the appearance/behavior-vs-structure split and the host-binding mechanics. Follow-up: “component vs directive?” — a component is a directive with a template and its own view; a directive with no template just augments a host element.
Source: Angular — Attribute directives.
What is a pipe, and how do you write one? Junior
A pipe transforms a value for display, right in the template, with the | syntax. Angular ships common ones — date, currency, json, async, uppercase — and you write a custom pipe as a class with @Pipe and a transform method. Pipes keep formatting logic out of the component and are composable (chain them) and parameterizable (pass arguments after a colon). The key property: a pure pipe (the default) only recomputes when its input reference changes, which makes it cheap.
@Pipe({ name: 'truncate' }) export class TruncatePipe implements PipeTransform { transform(v: string, max = 20): string { return v.length > max ? v.slice(0, max) + '…' : v; } } // usage: {{ title | truncate:30 }} · chain: {{ name | truncate | uppercase }}
Why it's asked / follow-up: it's everyday template work and a lead-in to the pure/impure question. Follow-up: “pipe or method call in the template?” — prefer a pure pipe; a method in an interpolation runs on every change-detection pass, which is a common performance trap (see the gotchas section).
Source: Angular — Pipes.
Pure vs impure pipes — what's the performance difference? Senior
A pure pipe (the default) is memoized by reference: Angular re-runs transform only when the input value's reference changes (or a primitive changes). Push an item onto an existing array and a pure pipe won't see it, because the array reference is unchanged — this is the same reference-equality model as OnPush. An impure pipe (pure: false) runs on every change-detection cycle, regardless of whether the input changed. That's how AsyncPipe can reflect a stream's latest emission — but a hand-written impure pipe doing real work is a performance landmine, since it fires constantly. Prefer a pure pipe over immutable data; if you're tempted to make one impure, reconsider the data shape first.
@Pipe({ name: 'filterActive', pure: false }) // runs EVERY CD cycle — costly export class FilterActivePipe implements PipeTransform { transform(users: User[]) { return users.filter(u => u.active); } } // Better: keep the pipe pure and hand it a new array reference on change, // or compute the filtered list once in the component (a computed signal).
Why it's asked / follow-up: impure pipes are a real-world performance foot-gun, and the answer forces the reference-equality model into the open. Follow-up: “why does my pure pipe ignore a mutated array?” — because the reference didn't change; return a new array (or a new reference) so the pipe recomputes.
Source: Angular — Pipes and change detection.
4 · Dependency injection
What is dependency injection in Angular, and why does it have a hierarchy? Mid
Dependency injection means a class declares what it needs (in its constructor, or via inject()) and Angular's injector supplies it — you never new a service yourself. That decouples consumers from construction and makes swapping implementations (e.g. a mock in tests) trivial. The hierarchy is the part interviewers push on: injectors form a tree that mirrors the app. When a component asks for a dependency, Angular walks up from that component's injector toward the root, using the first provider it finds. Provide a service at the root and everyone shares one instance; provide it on a component and that subtree gets its own instance. This is how you scope state.
// Root-provided: one shared singleton for the whole app. @Injectable({ providedIn: 'root' }) export class AuthService {} // Component-provided: a fresh instance per instance of this component's subtree. @Component({ selector: 'app-widget', providers: [WidgetState] }) export class WidgetComponent { state = inject(WidgetState); // resolved from this component's injector }
Why it's asked / follow-up: DI is Angular's spine; the hierarchy is what makes state-scoping and testing work. Follow-up: “how do you get one shared instance vs one per component?” — providedIn: 'root' for a singleton; list it in a component's providers for a per-subtree instance.
Source: Angular — Hierarchical injectors.
What does providedIn: 'root' do, and why is it tree-shakable?
Mid
providedIn: 'root' on an @Injectable registers the service with the root injector as an application-wide singleton — no need to list it in any providers array. The subtle win is tree-shaking: because the provider is declared on the service itself rather than in a separately-referenced providers array, the bundler can prove whether anything actually injects it. If nothing does, the service is dropped from the build. A service registered the old way (in an NgModule.providers) is retained whether or not it's used. That's why providedIn: 'root' is the recommended default for app-wide services.
@Injectable({ providedIn: 'root' }) // singleton + tree-shakable export class LoggerService { log(msg: string) { console.log(msg); } } // If no class ever injects LoggerService, it's removed from the bundle.
Why it's asked / follow-up: it's the modern default, and the tree-shaking rationale shows you understand why it beats module providers. Follow-up: “other providedIn values?” — 'platform' (shared across apps on the page) and 'any' (a distinct instance per lazy-loaded injector); 'root' covers almost everything.
Source: Angular — DI providers.
What is an InjectionToken, and when do you need one (plus multi-providers)?
Senior
DI usually keys on a class, but you can't inject something that has no class — a config object, a string, a function, an interface (interfaces vanish at runtime). An InjectionToken is a unique, typed key you create for exactly those cases; you provide a value for it and inject by the token. A multi-provider (multi: true) lets many providers contribute to one token, so injecting it yields an array — the mechanism behind extensible hooks like HTTP interceptors and app initializers, where several independent pieces register against a shared token.
export const API_URL = new InjectionToken<string>('API_URL'); // provide it providers: [{ provide: API_URL, useValue: 'https://api.example.com' }] // inject it (typed as string) const url = inject(API_URL); // multi-provider: many values under one token → injected as an array { provide: VALIDATORS, useClass: EmailValidator, multi: true }
Why it's asked / follow-up: it separates people who've only injected services from those who understand DI as a general lookup. Follow-up: “why not inject an interface?” — TypeScript interfaces are erased at compile time, so there's no runtime token; use an InjectionToken (or an abstract class) instead.
Source: Angular — InjectionToken.
What is the inject() function, and how does it differ from constructor injection?
Mid
inject() (Angular 14+) retrieves a dependency from the current injector as a function call, instead of via a constructor parameter. It's equivalent for the common case, but it composes better: it works cleanly in field initializers, lets you write reusable injection logic in plain functions, and avoids the ceremony of long constructor signatures and super() forwarding in subclasses. The one rule: inject() must run in an injection context — during construction of a component/service/directive, in a field initializer, or inside a factory — not from an arbitrary callback later.
// constructor injection (classic) constructor(private http: HttpClient) {} // inject() — same dependency, field initializer, composes into helpers private http = inject(HttpClient); function currentUser() { // reusable injection helper return inject(AuthService).user(); }
Why it's asked / follow-up: it's a current-Angular idiom, and the “injection context” rule is a real gotcha. Follow-up: “why does inject() throw in a setTimeout?” — you've left the injection context; capture the dependency during construction, or wrap with runInInjectionContext.
Source: Angular — Injecting a dependency.
Element vs environment injectors, and the resolution modifiers (@Optional/@Self/@SkipSelf/@Host)?
Senior
Angular has two parallel injector trees. Environment injectors come from providedIn: 'root', the bootstrap providers, and lazy-loaded routes — the app-wide layer. Element (node) injectors come from a component/directive's providers and follow the component tree. Resolution checks the element chain first, then the environment chain. The modifiers tune the walk: @Optional returns null instead of throwing if nothing's found; @Self restricts the search to this element's own injector; @SkipSelf starts at the parent (skipping self); @Host stops at the host component's boundary. With inject() you pass these as options ({ optional: true }, { self: true }, …).
// optional dependency — no throw if absent const theme = inject(THEME, { optional: true }) ?? defaultTheme; // force resolution from the PARENT injector, not this one const parentForm = inject(ControlContainer, { skipSelf: true });
Why it's asked / follow-up: the two-tree model and the modifiers are the deep end of Angular DI, and they explain otherwise-baffling “NullInjectorError: No provider for X” failures. Follow-up: “what does @SkipSelf solve?” — letting a directive inject the parent's instance of a service it also provides for its own children (nested form groups are the canonical case).
Source: Angular — Resolution modifiers.
5 · Change detection & rendering (extended)
How is change detection actually triggered? What does Zone.js do? Senior
In the traditional model, Angular has no idea when your data changed — so it re-checks after anything that might have changed it. Zone.js is how it knows “something happened”: at startup it monkey-patches the browser's async APIs — addEventListener, setTimeout/setInterval, Promise, XMLHttpRequest. When any of those fires, the patched version notifies Angular's zone (NgZone), which triggers ApplicationRef.tick() — one change-detection pass over the component tree. So change detection isn't polling; it's “run after every async event.” The important consequence: it fires on any event in the zone, whether or not that event changed anything you render — which is exactly what OnPush and, later, zoneless exist to reduce.
// Roughly what Zone.js patching accomplishes: // click / setTimeout / Promise.then / XHR callback fires // → NgZone notices the async task completed // → ApplicationRef.tick() // → change detection runs top-down over the component tree button.addEventListener('click', () => { /* your handler */ }); // ↑ the patched addEventListener wraps this so Angular is told about it
Why it's asked / follow-up: “Zone.js monkey-patches async APIs and calls tick()” is the answer that shows you understand the mechanism, not just the API. Follow-up: “does CD run because my data changed?” — no; it runs because an async event completed. Angular then checks whether bound values changed.
Source: Angular — Resolving zone pollution.
How does a change-detection pass walk the component tree? Senior
A pass runs top-down, once per cycle, in a single synchronous sweep from the root component to the leaves. For each component Angular re-evaluates the template's bound expressions and compares each against its previous value; if a value changed, it updates that piece of the DOM. Because the walk is depth-first and ordered parent-before-child, a parent's bindings are always evaluated before its children's. This ordering is deliberate and is why the unidirectional data flow rule exists: data should flow down during a pass, and a child must not change a parent's already-checked state — doing so triggers the ExpressionChangedAfterItHasBeenCheckedError (see the gotchas section). In development Angular runs a second verification pass to catch exactly that.
// One tick() = one top-down sweep: // AppComponent ← checked first // ├─ HeaderComponent ← then children, in order // └─ ListComponent // └─ RowComponent ← leaves last // Each: re-evaluate bindings, diff vs last value, patch DOM if changed.
Why it's asked / follow-up: the top-down, once-per-cycle model underlies OnPush, the unidirectional-flow rule, and the ExpressionChanged error — it's the foundation the rest of the section builds on. Follow-up: “can CD run twice in one cycle?” — in dev mode there's a second check-only verification pass; in production it runs once.
Source: Angular — Runtime performance.
What does ChangeDetectionStrategy.OnPush do, and when is an OnPush component checked?
Senior
OnPush tells Angular to skip a component (and its subtree) during a normal pass unless one of a few specific things happened. An OnPush component is checked when: (1) one of its @Input references changes (reference equality, not deep); (2) an event fires from the component or a child in its template; (3) markForCheck() is called on its ChangeDetectorRef; (4) the async pipe in its template emits (which calls markForCheck for you); and, in modern Angular, (5) a signal read in its template changes. The payoff is large: whole branches of the tree get skipped every tick. The cost is that you must feed the component immutable data — mutating an object in place won't change its reference, so OnPush won't notice (the classic pitfall in the next question).
@Component({ selector: 'app-row', changeDetection: ChangeDetectionStrategy.OnPush, template: `{{ user().name }}`, }) export class RowComponent { user = input.required<User>(); // checked when the input REFERENCE changes }
Why it's asked / follow-up: OnPush is the primary performance lever, and reciting its exact trigger list is a strong senior signal. Follow-up: “why did my OnPush view not update after I set a property?” — because none of the five triggers fired; you mutated data without a new reference, or updated outside a tracked event (call markForCheck(), or move to signals).
Source: Angular — Skipping subtrees (OnPush).
markForCheck vs detectChanges vs detach — what's the difference?
Senior
All three live on ChangeDetectorRef, and mixing them up is a common bug. markForCheck() does not run change detection now — it marks this component and its ancestors as needing a check on the next tick, which is exactly what an OnPush component needs when its data changed outside a tracked trigger. detectChanges() runs change detection synchronously, right now, for this component and its children — used when you truly need the view updated immediately (and the tool behind manual/detached CD). detach() removes the component from the CD tree entirely; it's then checked only when you call detectChanges() yourself — a manual-CD escape hatch for a hot component (e.g. a high-frequency ticker) whose updates you want to throttle by hand. reattach() puts it back.
private cdr = inject(ChangeDetectorRef); this.cdr.markForCheck(); // "check me next tick" (OnPush-friendly, async) this.cdr.detectChanges(); // "check me + children now" (synchronous) this.cdr.detach(); // skip me until I ask (manual CD)
Why it's asked / follow-up: the mark-for-later vs run-now distinction is precisely where people get OnPush wrong. Follow-up: “which for an OnPush view whose data changed in a setTimeout?” — markForCheck(); detectChanges() works too but forces a synchronous local pass you usually don't need.
Source: Angular — ChangeDetectorRef.
What is NgZone, and when would you run code with runOutsideAngular?
Senior
NgZone is Angular's wrapper around Zone.js: it's what fires a change-detection tick when async work completes inside “the Angular zone.” The problem it also creates: high-frequency async work — a mousemove handler, a requestAnimationFrame animation loop, a rapid setInterval — triggers a full tick every time it fires, even when it changes nothing you render. That's zone pollution. NgZone.runOutsideAngular(fn) runs that work outside the zone so it triggers no change detection; when you finally have something to render, you hop back in with ngZone.run(...) (or call markForCheck). It's the standard fix for animation/scroll/pointer-heavy code.
private zone = inject(NgZone); this.zone.runOutsideAngular(() => { const loop = () => { stepPhysics(); // runs every frame — NO change detection if (done) this.zone.run(() => this.finish()); // re-enter to render the result else requestAnimationFrame(loop); }; requestAnimationFrame(loop); });
Why it's asked / follow-up: it's the canonical performance fix for jank caused by too-frequent CD, and it proves you understand the zone/tick coupling. Follow-up: “how does zoneless change this?” — with no Zone.js there's no automatic tick to escape, so runOutsideAngular becomes unnecessary; you notify Angular explicitly instead (next question).
Source: Angular — NgZone.
What is zoneless change detection, and where does it stand now? Senior
Zoneless means running Angular without Zone.js at all. Instead of a global monkey-patch triggering a tick after every async event, the framework schedules change detection only when it's explicitly notified that something changed — and the modern notifiers do this for you: a signal updating, the async pipe emitting, an event binding firing, or a markForCheck() call. The wins are a smaller bundle (Zone.js is dropped, ~30 KB) and far fewer needless passes. It's no longer experimental: provideZonelessChangeDetection() stabilized in the v20 line and became the default for new projects in v21. To go zoneless cleanly, components should be OnPush and drive their views from signals / the async pipe rather than mutating plain fields and relying on a global tick.
// Opt an existing app in at bootstrap (default for new v21+ apps): bootstrapApplication(AppComponent, { providers: [ provideZonelessChangeDetection() ], }); // Now CD is scheduled by signals, the async pipe, events, and markForCheck — // NOT by a Zone.js tick after every setTimeout/Promise/event.
Why it's asked / follow-up: it's the direction of the whole framework and the payoff for signals; knowing it's now stable-and-default (not “experimental”) shows you're current. Follow-up: “what breaks going zoneless?” — code that mutated a field and leaned on Zone.js to notice; make those components signal- or async-pipe-driven, or call markForCheck(). See the zoneless-by-default release and the API-stable release.
Source: Angular — Zoneless.
Why does @for require track (and what did trackBy solve)?
Mid
When a list re-renders, Angular needs to know which DOM nodes correspond to which items so it can reuse them instead of destroying and recreating the lot. By default it tracks by object identity — so if you replace items with fresh objects that are equal by content (a common result of an HTTP refetch or an immutable update), Angular thinks every row is new, tears down every element, and rebuilds them: slow, and it drops focus/scroll/component state. Giving it a stable key — the old *ngFor's trackBy function, or the new @for (…; track item.id) — lets it match old and new by that key and touch only what actually changed. In the new control flow track is mandatory, precisely because forgetting it was such a common performance bug.
<!-- new control flow: track is required --> @for (user of users(); track user.id) { <app-user-card [user]="user"/> } // old *ngFor equivalent <div *ngFor="let user of users; trackBy: byId"></div> byId = (_: number, u: User) => u.id;
Why it's asked / follow-up: untracked lists are one of the most common real-world Angular perf problems, and @for now enforcing track is a recent, telling detail. Follow-up: “track by $index or by id?” — by a stable domain id; track $index re-associates rows to positions and reintroduces the same churn when the list reorders.
Source: Angular — @for and track.
6 · Reactivity: RxJS & Signals (extended)
Observable vs Promise — why does Angular lean on RxJS? Mid
A Promise represents a single future value, is eager (it starts the moment it's created), and can't be cancelled. An Observable is a stream of zero-to-many values over time, is lazy (nothing runs until you subscribe), can emit repeatedly, is cancellable (unsubscribe stops the work), and composes through operators (map, filter, debounceTime, the higher-order operators). Angular uses observables where those properties matter: HttpClient returns one (so a request can be cancelled and retried), the router exposes params as streams, reactive-forms valueChanges is a stream, and EventEmitter is observable-based. For a one-shot value a promise is fine; for anything that changes over time or needs cancellation, the observable earns its keep.
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs'; this.search.valueChanges.pipe( // a stream of keystrokes debounceTime(300), distinctUntilChanged(), switchMap(q => this.api.search(q)), // cancel the prior request ).subscribe(results => /* … */); // lazy: nothing runs before this
Why it's asked / follow-up: the lazy/cancellable/multi-value trio is the crisp answer, and the type-ahead example shows why it matters. Follow-up: “can you convert between them?” — firstValueFrom(obs) / lastValueFrom(obs) give a promise; from(promise) gives an observable.
Source: RxJS — Observable.
What does the async pipe do, and why is it recommended?
Mid
The async pipe subscribes to an observable (or promise) in the template, hands the latest emitted value to the view, and — the part that matters — unsubscribes automatically when the component is destroyed. That kills the single most common Angular memory leak (forgotten manual subscriptions) for free. It also calls markForCheck() on each emission, so it works correctly with OnPush and is a natural fit for zoneless. The idiom is to keep data as observables and let the template subscribe via async, rather than subscribing in the class and storing the value in a field.
// component: expose the stream, don't subscribe manually users$ = this.api.getUsers(); // Observable<User[]> <!-- template: subscribe + auto-unsubscribe + OnPush-friendly --> @if (users$ | async; as users) { @for (u of users; track u.id) { <p>{{ u.name }}</p> } }
Why it's asked / follow-up: “auto-unsubscribes and marks for check” is the answer that shows you know why it's preferred over manual subscription. Follow-up: “doesn't | async subscribe twice if used twice in a template?” — yes; alias it once with @if (x$ | async; as x) (or a @let) and reuse the bound value.
Source: Angular — AsyncPipe.
switchMap vs mergeMap vs concatMap vs exhaustMap — how do you choose?
Senior
All four are higher-order mapping operators: each maps a source emission to an inner observable and flattens the result. They differ in what happens when a new source value arrives while an inner observable is still running. switchMap cancels the previous inner and switches to the new one — ideal for type-ahead/search, where only the latest query matters. mergeMap (aka flatMap) runs all inners concurrently — good for independent parallel work, but order isn't guaranteed. concatMap queues inners and runs them one at a time in order — use when sequence matters (ordered writes). exhaustMap ignores new source values while an inner is active — perfect for a submit button, to drop double-clicks until the request finishes.
// search: cancel the stale request → switchMap query$.pipe(switchMap(q => api.search(q))); // submit: ignore double-clicks until done → exhaustMap clicks$.pipe(exhaustMap(() => api.save(form))); // ordered writes: one after another → concatMap edits$.pipe(concatMap(e => api.persist(e)));
Why it's asked / follow-up: picking the wrong flattening operator causes race conditions, out-of-order results, and duplicate submits — so this is a favorite senior question. Follow-up: “default choice?” — there isn't one; name the behavior you want (cancel / parallel / queue / ignore) and pick accordingly — switchMap for reads, exhaustMap or concatMap for writes.
Source: RxJS — switchMap (see also mergeMap / concatMap / exhaustMap).
Subject vs BehaviorSubject (and the other Subjects)?
Mid
A Subject is both an observable and an observer — you can next() values into it and many observers can subscribe, so it's how you multicast (an event bus, a cross-component notification). A plain Subject has no memory: a subscriber only receives values emitted after it subscribes. A BehaviorSubject holds a current value, requires an initial one, and replays it immediately to every new subscriber — which makes it the classic choice for simple shared state, because a late subscriber still gets the latest value right away. (ReplaySubject replays the last n; AsyncSubject emits only the final value on complete.)
const clicks = new Subject<void>(); // no memory — event stream clicks.next(); // a late subscriber misses this const user = new BehaviorSubject<User | null>(null); // holds current value user.next(ada); user.subscribe(u => /* immediately gets 'ada' */); console.log(user.value); // synchronous current value
Why it's asked / follow-up: the “replays the current value to new subscribers” property is the practical difference and a frequent source of “why is my subscriber empty” bugs. Follow-up: “expose a Subject from a service directly?” — no; expose it as .asObservable() so consumers can't next() into your state.
Source: RxJS — Subject.
How do you manage subscriptions and avoid leaks (takeUntilDestroyed)?
Senior
A manual .subscribe() keeps running until the observable completes or you unsubscribe — and long-lived streams (a router param stream, a BehaviorSubject, an interval) never complete, so a subscription that outlives its component is a memory leak (and a source of “code runs after the view is gone” bugs). The best fix is to not subscribe manually — use the async pipe, which cleans up on destroy. When you must subscribe in the class, the modern tool is takeUntilDestroyed() (Angular 16+), which ties the subscription's lifetime to the component/service and unsubscribes automatically on destroy. The older patterns — a takeUntil(destroy$) Subject you next() in ngOnDestroy, or collecting into a Subscription and calling unsubscribe() — still work but are more ceremony.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; export class TickerComponent { constructor() { interval(1000).pipe( takeUntilDestroyed(), // auto-unsubscribe on destroy ).subscribe(t => this.tick = t); } } // (called outside an injection context? pass a DestroyRef explicitly.)
Why it's asked / follow-up: subscription leaks are the most common real Angular bug, and takeUntilDestroyed is the current-best answer. Follow-up: “when do you NOT need to unsubscribe?” — observables that complete on their own (a single HttpClient request, a take(1)), and anything consumed through the async pipe. See the rxjs-interop release.
Source: Angular — takeUntilDestroyed.
What are signals — signal, computed, effect?
Mid
Signals are Angular's built-in fine-grained reactive primitive (developer preview in v16, core APIs stable in v20). A signal(x) is a writable container: call it to read (count()), and set/update to change it. A computed(() => …) is a derived, read-only signal that recomputes lazily and memoizes — it re-runs only when a signal it read actually changed. An effect(() => …) runs a side effect whenever any signal it reads changes. The key property is automatic dependency tracking: reading a signal inside a computed/effect/template registers a dependency, so Angular knows exactly what to update when it changes — enabling per-component (eventually per-binding) change detection and zoneless.
const count = signal(0); const doubled = computed(() => count() * 2); // derived, memoized effect(() => console.log('count is', count())); // re-runs when count changes count.set(5); // doubled() is now 10; the effect logs 5 count.update(n => n + 1);
Why it's asked / follow-up: signals are the framework's current center of gravity; the “automatic dependency tracking” insight is what interviewers want. Follow-up: “computed vs a getter?” — computed memoizes and only recomputes when a dependency changed; a getter runs every access. See the signals-stable release.
Source: Angular — Signals.
Signals vs observables — when do you reach for each, and how do they interop? Senior
They solve overlapping but different problems. Signals model synchronous state — a value that exists right now and its derivations — with always-available reads, no subscription, and no operators. Observables model asynchronous streams of events over time and bring RxJS's operator toolbox (debounceTime, the higher-order operators, retry/backoff, combination). The pragmatic split most teams land on: use signals for component and shared UI state; use observables for async pipelines and events (HTTP, websockets, debounced input). They interoperate through @angular/core/rxjs-interop: toSignal(obs$) exposes a stream as a signal (and auto-unsubscribes), and toObservable(sig) turns a signal into a stream so you can apply operators.
import { toSignal, toObservable } from '@angular/core/rxjs-interop'; // stream → signal (great in templates; unsubscribes for you) user = toSignal(this.api.currentUser$, { initialValue: null }); // signal → stream (so you can debounce, switchMap, etc.) results = toSignal( toObservable(this.query).pipe(debounceTime(300), switchMap(q => api.search(q))), { initialValue: [] });
Why it's asked / follow-up: “signals for state, observables for async streams, bridge with toSignal/toObservable” is the current, nuanced answer — not “signals replace RxJS.” Follow-up: “do signals replace RxJS?” — no; they replace RxJS for local state management, but RxJS stays the tool for time-based event orchestration.
7 · Forms
Template-driven vs reactive forms — how do you choose? Mid
Angular has two forms APIs. Template-driven forms build the model implicitly from directives in the template (ngModel, ngForm); they're asynchronous, simple for small forms, and closer to AngularJS-style two-way binding. Reactive (model-driven) forms define the form model explicitly in the component as FormControl/FormGroup objects and bind the template to it; they're synchronous, immutable-ish, easier to unit-test (the model is a plain object you can assert on), and scale to dynamic and complex validation. The rule of thumb: reactive forms for anything non-trivial — dynamic controls, cross-field validation, typed models — and template-driven only for the simplest cases.
// reactive: model lives in the component form = new FormGroup({ email: new FormControl('', [Validators.required, Validators.email]), }); <!-- template binds to it --> <form [formGroup]="form"><input formControlName="email"></form> <!-- template-driven equivalent: model is implicit --> <input [(ngModel)]="email" name="email" required email>
Why it's asked / follow-up: it's a real architectural fork, and “reactive is synchronous and testable” is the discriminating detail. Follow-up: “can you mix them?” — on one control, no; pick one API per form. A signal-based Signal Forms model is also arriving in modern Angular as a third option.
Source: Angular — Forms overview.
FormControl, FormGroup, FormArray — what does each model?
Mid
They're the building blocks of a reactive form, all extending AbstractControl (which carries value, validity, and the valueChanges/statusChanges streams). A FormControl is a single field — one value plus its validators and state. A FormGroup is a fixed set of named controls treated as one unit (its value is an object); it's valid only when all children are. A FormArray is an ordered, dynamic list of controls (its value is an array) — the tool for “add another phone number” where the count isn't known ahead of time. You compose them into a tree; FormBuilder (or the newer NonNullableFormBuilder) is sugar for building that tree tersely.
const fb = inject(FormBuilder); form = fb.group({ name: ['', Validators.required], emails: fb.array([ fb.control('') ]), // dynamic list }); addEmail() { (this.form.get('emails') as FormArray).push(fb.control('')); }
Why it's asked / follow-up: knowing which container to reach for (fixed group vs dynamic array) is everyday reactive-forms work. Follow-up: “how do you read validity/value reactively?” — subscribe to form.valueChanges / form.statusChanges (both observables), or read form.value / form.valid synchronously.
Source: Angular — Reactive forms.
Sync, async, and custom validators — how do they work? Senior
A sync validator is a function that takes a control and returns null if valid or an errors object (e.g. { tooShort: true }) if not. Angular ships common ones on Validators (required, email, minLength, pattern), and a custom one is just your own function of that shape. An async validator returns an Observable or Promise of that errors-object-or-null — used for checks that need a server (is this username taken?); while it's pending the control's status is PENDING. Sync validators run first; async ones run only if the sync ones pass. Cross-field rules (password === confirm) go on the parent FormGroup as a group-level validator.
// custom sync validator function noSpaces(c: AbstractControl): ValidationErrors | null { return typeof c.value === 'string' && c.value.includes(' ') ? { spaces: true } : null; } // async validator: server check → PENDING until it resolves usernameTaken = (c: AbstractControl) => this.api.exists(c.value).pipe(map(t => t ? { taken: true } : null)); email = new FormControl('', { validators: [noSpaces], asyncValidators: [usernameTaken] });
Why it's asked / follow-up: the errors-object shape and the sync-then-async ordering are the details people miss, and cross-field validation is a common real requirement. Follow-up: “where does a cross-field validator live?” — on the enclosing FormGroup, since it needs to see multiple controls at once.
Source: Angular — Form validation.
What are typed reactive forms, and what is ControlValueAccessor for?
Senior
Typed reactive forms (Angular 14+) made the forms API generic: a FormGroup now carries the type of its controls, so form.value, form.get('email'), and patchValue are all type-checked instead of returning any. One nuance: a control's value type includes null (a reset sets it to null) unless you use the nonNullable option or NonNullableFormBuilder. ControlValueAccessor solves a different problem: it's the bridge that lets a custom component participate in forms like a native input. By implementing its four methods — writeValue (model → view), registerOnChange / registerOnTouched (view → model), and setDisabledState — your component can be used with formControlName or [(ngModel)].
// typed form: form.value is { email: string }, checked at compile time form = new FormGroup({ email: new FormControl('', { nonNullable: true }) }); // a custom control implements ControlValueAccessor to join a form export class StarRating implements ControlValueAccessor { writeValue(v: number) { /* model → view */ } registerOnChange(fn: (v: number) => void) { /* view → model */ } registerOnTouched(fn: () => void) {} setDisabledState(disabled: boolean) {} }
Why it's asked / follow-up: typed forms and CVA are the two “you've built real forms” signals. Follow-up: “why is form.value sometimes partially optional?” — disabled controls are excluded from value; use getRawValue() to include them. See the typed-forms release.
Source: Angular — Typed forms.
8 · Routing
How do you configure routing (provideRouter), and what renders a route?
Junior
You declare an array of Route objects — each mapping a path to a component (or a lazy loader) — and register it with provideRouter(routes) at bootstrap (the standalone replacement for RouterModule.forRoot). In the template, <router-outlet> is the placeholder where the matched component renders, and routerLink creates navigation links (use it instead of href so navigation stays client-side). Order matters — the router takes the first match — so put a wildcard ** (404) route last.
const routes: Routes = [ { path: '', component: HomeComponent }, { path: 'users/:id', component: UserComponent }, { path: '**', component: NotFoundComponent }, // wildcard last ]; bootstrapApplication(AppComponent, { providers: [provideRouter(routes)] }); <!-- <a routerLink="/users/42">Profile</a> → renders in <router-outlet/> -->
Why it's asked / follow-up: it's the baseline routing setup and the routerLink-vs-href distinction is a common junior slip. Follow-up: “why not href?” — href triggers a full page reload; routerLink does client-side navigation without one.
Source: Angular — Routing.
How do you read route params and query params — snapshot vs the observable? Mid
You get params from the injected ActivatedRoute. There are two ways, and the difference is a real gotcha. The snapshot (route.snapshot.paramMap.get('id')) reads the value once. The observable (route.paramMap, a stream) emits every time the params change. The catch: if you navigate from /users/1 to /users/2 and Angular reuses the same component instance (same route, different param), the snapshot won't update — only the observable fires. So use the snapshot only when the component is created fresh per navigation; use the observable (or the newer withComponentInputBinding, which binds params straight to @Input()s) when the same component can be reused across param changes. Query params come from route.queryParamMap, same two flavors.
private route = inject(ActivatedRoute); // reacts to /users/1 → /users/2 even if the component is reused this.route.paramMap.pipe( map(p => p.get('id')), switchMap(id => this.api.getUser(id!)), ).subscribe(u => this.user = u); // snapshot: fine only if the component is recreated per navigation const id = this.route.snapshot.paramMap.get('id');
Why it's asked / follow-up: the “snapshot doesn't update on same-component navigation” bug is a classic, and the observable is the fix. Follow-up: “route param vs query param?” — path params (:id) identify a resource and are part of the route; query params (?sort=asc) are optional modifiers that don't change which route matched.
Source: Angular — Reading route state.
How does lazy loading work with standalone components? Mid
Lazy loading defers downloading a route's code until the user navigates there, shrinking the initial bundle. With standalone components you lazy-load at the route level using loadComponent (a single component) or loadChildren (a set of child routes) — each a function returning a dynamic import(). The bundler code-splits at that import, so the chunk ships only when needed. This replaced the older NgModule-based loadChildren: () => import(…).then(m => m.FeatureModule) pattern. Route-level providers scoped to a lazy route create a new environment injector for that feature.
const routes: Routes = [ { path: 'admin', loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent) }, { path: 'reports', loadChildren: () => import('./reports/routes').then(m => m.REPORT_ROUTES) }, ];
Why it's asked / follow-up: it's the primary bundle-size lever and the standalone loadComponent form is current-Angular. Follow-up: “how do you preload lazy routes?” — provideRouter(routes, withPreloading(PreloadAllModules)), or a custom strategy to preload only likely-next routes.
Source: Angular — Lazy-loaded routes.
What are route guards (canActivate/canMatch/canDeactivate) and resolvers?
Senior
Guards are functions the router runs to allow, block, or redirect navigation; each returns a boolean/UrlTree (or an async one). canActivate gates entering a route (auth checks); canDeactivate gates leaving one (“discard unsaved changes?”); canMatch decides whether a route matches at all — it runs before lazy code loads, so it can skip downloading a feature's bundle for an unauthorized user (a capability the old canLoad had, now unified under canMatch). Modern guards are plain functions using inject(), not classes. A resolver pre-fetches data before the route activates, so the component renders with data already in hand instead of flashing an empty state.
// functional guard (v15+) — no class needed export const authGuard: CanActivateFn = () => { const auth = inject(AuthService); return auth.isLoggedIn() ? true : inject(Router).parseUrl('/login'); }; { path: 'admin', canActivate: [authGuard], canMatch: [authGuard], // blocks BEFORE the lazy chunk downloads loadComponent: () => import('./admin').then(m => m.Admin) }
Why it's asked / follow-up: the canMatch-runs-before-lazy-load detail and functional guards are strong current-Angular signals. Follow-up: “canActivate vs canMatch for an auth wall?” — canMatch if you also want to avoid downloading the protected bundle for unauthorized users; canActivate if it's already loaded.
Source: Angular — Route guards.
9 · HTTP & data
How does HttpClient work, and why does it return an observable?
Mid
HttpClient (registered with provideHttpClient()) is Angular's HTTP API, and its methods return observables — which has concrete consequences. The request is lazy: nothing is sent until you subscribe (or the async pipe does). It's cancellable: unsubscribing aborts the in-flight request, which is exactly what switchMap exploits to cancel a stale search. And it composes with operators for retry, timeout, and mapping. You get typed responses by passing a generic (get<User>()), which types the parsed JSON body. By default it emits the body and completes after one value.
private http = inject(HttpClient); getUser(id: string): Observable<User> { return this.http.get<User>(`/api/users/${id}`).pipe( retry(2), catchError(err => { this.report(err); return EMPTY; }), ); } // nothing is sent until something subscribes (often via | async)
Why it's asked / follow-up: the lazy + cancellable + typed trio is the reason HttpClient is observable-based, not promise-based. Follow-up: “does an HTTP subscription leak?” — no; it completes after the single response, so you don't have to unsubscribe (though async is still cleaner).
Source: Angular — Making HTTP requests.
What are HTTP interceptors, and what are they used for? Senior
An interceptor sits in the middle of every HttpClient request/response, so you can inspect or transform both in one place instead of repeating logic at every call site. The modern form is a functional interceptor (v15+): a function that receives the request and a next handler, and returns the resulting stream. They chain in order, forming a pipeline. Canonical uses: attach an auth token to every request, log or time requests, add caching, set base headers, and — via catchError on the response — do centralized error handling (redirect on 401, surface a toast on 500, retry with backoff). Requests are immutable, so you clone() to modify.
export const authInterceptor: HttpInterceptorFn = (req, next) => { const token = inject(AuthService).token(); const authed = req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); return next(authed).pipe( catchError(err => { if (err.status === 401) inject(Router).navigate(['/login']); return throwError(() => err); }), ); }; // register: provideHttpClient(withInterceptors([authInterceptor]))
Why it's asked / follow-up: interceptors are where cross-cutting HTTP concerns belong, and the functional form + clone() immutability are the current details. Follow-up: “order of interceptors?” — they run in registration order on the way out and reverse order on the way back — put auth before logging so the log sees the final request.
Source: Angular — Interceptors.
What are SSR and hydration in Angular, at a high level? Mid
Server-side rendering renders the app to HTML on the server so the browser shows content immediately — better first-contentful paint and SEO than waiting for a client-only bundle to boot. Hydration is what happens next: instead of throwing away that server HTML and re-rendering from scratch (which caused a flicker in the old model), non-destructive hydration reuses the existing DOM and just attaches Angular's event listeners and state to it. Modern Angular adds incremental hydration — hydrate parts of the page on demand (pairing with @defer) — and event replay, which captures clicks that happen before hydration finishes and replays them after. You enable it with provideClientHydration(). This is a per-project decision; know that it exists and roughly how it works.
bootstrapApplication(AppComponent, { providers: [ provideClientHydration(withEventReplay()), // reuse server HTML, replay early clicks provideHttpClient(withFetch()), ], });
Why it's asked / follow-up: SSR/hydration is increasingly common, and knowing “non-destructive hydration reuses the DOM” distinguishes you from “SSR is just rendering on the server.” Follow-up: “what breaks under SSR?” — direct window/document access (no DOM on the server) — guard with isPlatformBrowser or afterNextRender. See the incremental-hydration release.
Source: Angular — Hydration.
10 · Lifecycle & modern Angular
What are the component lifecycle hooks, and when does each fire? Mid
Angular calls hooks at defined moments in a component's life. In order: ngOnChanges (before ngOnInit and whenever an @Input reference changes; receives a SimpleChanges map), ngOnInit (once, after the first inputs are set — the place for setup that needs inputs), ngDoCheck (every CD run — use sparingly), ngAfterContentInit/ngAfterContentChecked (projected content ready), ngAfterViewInit/ngAfterViewChecked (the component's own view and its @ViewChildren ready), and ngOnDestroy (just before teardown — unsubscribe, clear timers). Two that trip people up: ngOnInit runs once (not per CD), and @ViewChild refs are null until ngAfterViewInit.
export class WidgetComponent implements OnInit, OnDestroy { data = input<Data>(); ngOnChanges(c: SimpleChanges) { /* input reference changed */ } ngOnInit() { /* once — inputs available, do setup */ } ngAfterViewInit() { /* @ViewChild refs now resolved */ } ngOnDestroy() { /* cleanup — unsubscribe, clear timers */ } }
Why it's asked / follow-up: the order and the “ngOnInit once vs ngDoCheck every pass” distinction are bread-and-butter. Follow-up: “constructor vs ngOnInit?” — the constructor is for DI wiring only; inputs aren't set yet, so do input-dependent work in ngOnInit. (Modern Angular also offers afterNextRender/afterRender for DOM-timing work, and signal-based components often need fewer hooks.)
Source: Angular — Component lifecycle.
What does “standalone” mean, and what are the provide* APIs?
Junior
A standalone component/directive/pipe declares its own template dependencies in an imports array and needs no NgModule. Since v19 it's the default, so standalone: true is usually implied. The features that used to be configured through modules (RouterModule.forRoot, HttpClientModule, BrowserAnimationsModule) now have tree-shakable provide* functions — provideRouter, provideHttpClient, provideAnimationsAsync — that you pass to bootstrapApplication or a route's providers. The upshot: less boilerplate, clearer dependency wiring (each component's needs are visible on the component), and better tree-shaking.
bootstrapApplication(AppComponent, { providers: [ provideRouter(routes), provideHttpClient(), provideAnimationsAsync(), ], }); // no AppModule, no imports: [RouterModule, HttpClientModule, ...]
Why it's asked / follow-up: standalone + provide* is the shape of every new Angular app; not knowing it flags stale knowledge. Follow-up: “can standalone and NgModule coexist?” — yes; a standalone component can import an NgModule and a module can import a standalone component, which is what makes incremental migration possible.
Source: Angular — Standalone components.
What are deferrable views (@defer)?
Mid
@defer (stable since Angular 18) lazy-loads a chunk of a template — and the components it uses — declaratively, without hand-writing dynamic imports or routing it. You wrap markup in a @defer block and give it a trigger: on idle (browser idle time, the default), on viewport (when it scrolls into view), on interaction, on hover, on timer, or a condition via when. Companion blocks @placeholder, @loading, and @error handle the states. It's the built-in answer to “defer this heavy widget until it's actually needed,” and it pairs with incremental hydration for SSR.
@defer (on viewport) { <app-heavy-chart [data]="data()"/> // loaded only when scrolled into view } @placeholder { <div class="skeleton"></div> } @loading (minimum 200ms) { <app-spinner/> } @error { <p>Couldn't load the chart.</p> }
Why it's asked / follow-up: @defer is a headline modern-Angular feature and the declarative alternative to manual lazy loading inside a view. Follow-up: “how is this different from route-level lazy loading?” — route lazy-loading defers a whole route's code; @defer defers a piece of an already-loaded component's template on a fine-grained trigger. See the deferrable-views release.
Source: Angular — Deferrable views.
How do you migrate off NgModules, and how do you test a component (TestBed)?
Mid
Migration is incremental, not a rewrite. Angular ships an ng generate @angular/core:standalone schematic that runs in stages: convert declarations to standalone, remove the now-unnecessary NgModules, and switch bootstrapping to bootstrapApplication. Because standalone and modules interoperate, you can convert leaf components first and work upward. For testing, TestBed configures a testing module and renders a component into a ComponentFixture; with standalone components you put the component under test in imports (not declarations). You query the rendered DOM through fixture.debugElement, drive change detection with fixture.detectChanges(), and override providers to inject fakes.
// migration (run the schematic in its three modes) // ng g @angular/core:standalone // component test with a standalone component TestBed.configureTestingModule({ imports: [UserComponent], // standalone → imports providers: [{ provide: UserService, useValue: fake }], }); const fixture = TestBed.createComponent(UserComponent); fixture.detectChanges(); expect(fixture.nativeElement.textContent).toContain('Ada');
Why it's asked / follow-up: it shows you can move a real codebase forward and that you know the standalone testing shape (imports, not declarations). Follow-up: “must you migrate?” — no; NgModules still work, but new code is standalone-by-default and the tooling makes migration low-risk.
Source: Angular — Migrate to standalone.
11 · AngularJS (1.x) legacy
AngularJS vs Angular — why are they considered different frameworks? Junior
AngularJS is the 1.x line (2010–2021); Angular is 2 and later, a complete 2016 rewrite, not an upgrade. They differ at almost every level: AngularJS used JavaScript, $scope, controllers, and $scope.$watch with a digest cycle; Angular is TypeScript-first with a component tree, decorators, a real DI system, and (originally) Zone.js-driven change detection. AngularJS's two-way binding was global and convenient but hard to scale and reason about; Angular's is opt-in sugar over one-way pieces. There is no in-place upgrade path — you migrate, not bump a version. The shared name causes endless confusion, which is why the distinction is worth stating precisely.
// AngularJS (1.x): controller + $scope app.controller('Ctrl', function ($scope) { $scope.count = 0; $scope.inc = function () { $scope.count++; }; // digest picks up the change }); // Angular (2+): class + typed template + explicit reactivity export class Ctrl { count = signal(0); inc() { this.count.update(n => n + 1); } }
Why it's asked / follow-up: it surfaces whether you understand they're separate frameworks — a surprisingly common point of confusion in job postings and résumés. Follow-up: “is AngularJS still supported?” — no; see the EOL question below. For the version history, see the AngularJS-vs-Angular callout on the versions page.
What was the digest cycle / dirty checking, and how does modern change detection differ? Mid
AngularJS detected changes by dirty checking. Each bound expression registered a $watch; the digest cycle (triggered by $scope.$apply/$digest) looped over every watcher, compared each expression's current value to its last, and re-ran the loop until nothing changed (bounded to avoid infinite loops). It was simple but scaled poorly: many watchers meant a slow digest, and changes made outside Angular's awareness (a raw setTimeout, a third-party callback) required a manual $scope.$apply to be noticed. Angular replaced this: Zone.js knew when async work finished and triggered a top-down pass over the component tree (no per-watcher loop), and modern signals go further — fine-grained dependency tracking updates only what actually depends on a changed value, and zoneless drops the global trigger entirely.
// AngularJS: change outside the framework needs a manual kick setTimeout(function () { $scope.count++; $scope.$apply(); // tell the digest to run — otherwise the view won't update }, 1000); // Angular: Zone.js (or a signal write) schedules the update automatically.
Why it's asked / follow-up: contrasting dirty checking with top-down/signal-based CD shows you understand why the rewrite happened, not just that it did. Follow-up: “the digest's biggest weakness?” — cost scaling with the number of watchers, and the constant need for $apply at framework boundaries — exactly the problems Zone.js and then signals were designed to remove.
How do you migrate from AngularJS, and when did it reach end-of-life? Mid
AngularJS reached end-of-life on December 31, 2021 — no more releases, security patches, or official support — so remaining 1.x apps are running unmaintained. Migration to modern Angular is a rewrite, but it can be incremental rather than big-bang: the @angular/upgrade library (ngUpgrade) runs both frameworks side by side in a hybrid app, bootstrapped with UpgradeModule. You can downgrade Angular components/services to use inside AngularJS, and upgrade AngularJS components/services to use inside Angular, then convert screen by screen until nothing 1.x remains and you drop the hybrid layer. For many teams a from-scratch rewrite is simpler than a long hybrid period; the right choice depends on app size and how cleanly the two halves separate.
// Hybrid app: run AngularJS + Angular together during migration import { UpgradeModule } from '@angular/upgrade/static'; // downgrade an Angular service so AngularJS code can inject it, and vice versa, // converting one component/route at a time until the 1.x code is gone.
Why it's asked / follow-up: the EOL date and the ngUpgrade/hybrid strategy are exactly what a team maintaining a legacy app needs to know. Follow-up: “hybrid or rewrite?” — hybrid preserves a shippable app throughout a long migration; a rewrite is often faster for small apps or where the AngularJS code is too tangled to bridge cleanly.
12 · Classic gotchas
What causes ExpressionChangedAfterItHasBeenCheckedError?
Senior
This dev-mode-only error means a bound value changed after Angular had already checked it in the same change-detection pass. In development Angular runs a second, verification pass immediately after the first; if a binding's value differs between the two, it throws — because it means your code violated unidirectional data flow by mutating already-checked state mid-pass. The usual culprits: a child changing a parent's bound property during ngAfterViewInit/ngAfterContentInit (the parent was already checked), or a getter in a template that returns a different value each call. Fixes: move the change earlier (ngOnInit), defer it to the next tick, or push it into a signal/observable so the change schedules its own pass instead of mutating within the current one.
// ❌ changing a parent-bound value in ngAfterViewInit → the error in dev ngAfterViewInit() { this.title = 'ready'; } // parent already checked this pass // ✅ defer so it lands on the NEXT change-detection pass ngAfterViewInit() { queueMicrotask(() => this.title = 'ready'); } // (or set it in ngOnInit, or drive it from a signal)
Why it's asked / follow-up: everyone hits it, and the good answer explains the dev-mode double-check and unidirectional flow — not just “wrap it in setTimeout.” Follow-up: “why doesn't it happen in production?” — the verification pass runs only in dev mode; the underlying bug is still real, so fix it rather than hide it.
Source: Angular — NG0100: ExpressionChangedAfterItHasBeenChecked.
Why do forgotten subscriptions leak, and how do you prevent it? Senior
A manual .subscribe() to a stream that never completes — a router param stream, a BehaviorSubject, an interval, a global event bus — keeps the callback (and everything it closes over, including the component) alive after the component is destroyed. The component can't be garbage-collected, its callback keeps running against a dead view, and over a long session these accumulate. The fix hierarchy: prefer the async pipe (auto-unsubscribes); when you must subscribe in code, use takeUntilDestroyed(); failing that, tie subscriptions to ngOnDestroy. This is the same underlying issue as the reactivity section's subscription-management question — it recurs here because it's the single most common Angular leak in real code.
// ❌ leaks: interval never completes, subscription outlives the component ngOnInit() { interval(1000).subscribe(() => this.poll()); } // ✅ auto-clean on destroy constructor() { interval(1000).pipe(takeUntilDestroyed()).subscribe(() => this.poll()); }
Why it's asked / follow-up: it's the most frequent real-world leak, and interviewers want to see you reach for async/takeUntilDestroyed rather than hand-rolling unsubscribe bookkeeping. Follow-up: “how would you catch these in review?” — a lint rule against bare .subscribe() in components, and a bias toward async-pipe or signal-driven templates.
Source: Angular — takeUntilDestroyed.
Why doesn't my OnPush component update when I mutate an object or array? Senior
Because OnPush checks an input by reference, not by content. If you push() onto an array or set a property on an object that's bound as an input, the reference is unchanged — so from OnPush's point of view nothing happened, and the view isn't re-checked. The fix is to treat that data as immutable: create a new array/object so the reference changes (spread, map, a new object literal). This is the same reference-equality contract as pure pipes, and it's why signals (which notify explicitly on write) sidestep the whole problem. If you're stuck with mutation, markForCheck() forces a re-check — but immutability is the real fix.
// ❌ OnPush child won't see this — same array reference this.items.push(newItem); // ✅ new reference → OnPush re-checks this.items = [...this.items, newItem]; // or hold items in a signal: this.items.update(xs => [...xs, newItem]);
Why it's asked / follow-up: it's the number-one OnPush surprise and forces the reference-equality model into the open. Follow-up: “why do signals avoid this?” — a signal write notifies Angular directly, so the component is marked dirty regardless of whether the underlying object's reference changed.
Source: Angular — Skipping subtrees (OnPush).
What goes wrong with a list that has no track / trackBy?
Mid
Without a stable key, Angular tracks list items by object identity. So when you replace the array with freshly-constructed objects (the normal result of an HTTP refetch or an immutable update), every item looks new: Angular destroys all the existing DOM nodes and their component instances and rebuilds the entire list. Beyond being slow on large lists, it throws away per-row state — input focus, scroll position, an expanded row, an in-progress animation — because the elements holding that state were destroyed. Providing a stable domain key lets Angular reuse the nodes for unchanged items and touch only what changed. It's why the new @for makes track mandatory.
// ❌ refetch replaces objects → whole list re-created, focus/scroll lost @for (row of rows(); track row) { <input [value]="row.name"> } // ✅ stable key → nodes reused, state preserved @for (row of rows(); track row.id) { <input [value]="row.name"> }
Why it's asked / follow-up: it's a real, visible bug (lost focus in an editable list) with a one-line fix, and it ties back to change detection. Follow-up: “is track $index ever right?” — only for a static list that never reorders; for anything sortable or filterable, track a stable id.
Source: Angular — @for and track.
Why did I suddenly get two instances of a “singleton” service? Senior
Because a service is a singleton only within the injector that provides it — and it's easy to accidentally provide it in more than one place. Listing a service in a component's (or a lazy route's) providers creates a new instance for that subtree, shadowing the root one. So a service you assumed was app-wide state can quietly become two: the components under the extra provider share one instance, everyone else shares another, and they drift out of sync. The rule: for a true app-wide singleton, provide it exactly once — via providedIn: 'root' — and do not also list it in a component's providers unless you specifically want a scoped instance. When you do want per-component state, that scoping is the feature, not a bug.
@Injectable({ providedIn: 'root' }) // intended: one app-wide instance export class CartService {} @Component({ selector: 'app-widget', providers: [CartService] }) // ❌ oops: a SECOND instance for this subtree export class WidgetComponent {}
Why it's asked / follow-up: it's a subtle, high-confusion bug that tests whether you really understand hierarchical DI and instance scoping. Follow-up: “how would you diagnose it?” — look for the service in any component/route providers array; remove the stray one so resolution falls through to the single root instance.
Source: Angular — Hierarchical injectors.
Every answer links its primary source inline — the official Angular documentation and, for the observable and operator answers, the RxJS documentation. The questions are a curated set of the topics an Angular interviewer commonly covers, not a copy of any question bank. This page covers Angular the framework and assumes TypeScript fluency; the language beneath it is covered on the TypeScript interview page, and the wider ecosystem (Angular Material, NgRx, Nx) is out of scope. Feature-arrival details cross-link to the Angular version reference. Last updated July 2026.
Mungomash LLC · More on Angular