Modern Angular for interviews: standalone components, signals, input/output/model, control flow, change detection, DI, routing, forms, HttpClient and testing.
AMCompiled by Aditya Mishra · Technical Lead, BNP Paribas
Modern Angular on one page: standalone components, signals, built-in control flow and inject(), with the classic NgModule and @Input() forms interviewers still ask about. Checked against Angular 22.
Architecture at a glance
An app is a tree of components (class + template + styles). Services hold shared logic and state and reach components through dependency injection.
Standalone is the default (standalone: true is implied since v19): a component lists what its template uses in imports. NgModules (declarations, imports, exports, providers) still work; set standalone: false to declare a component in one.
@if (user(); as u) { <p>Hi {{ u.name }}</p>} @else { <button (click)="login()">Log in</button>}@for (item of items(); track item.id; let i = $index) { <li [class.odd]="$odd">{{ i + 1 }}. {{ item.name }}</li>} @empty { <li>No items</li>}@switch (status()) { @case ('ok') { <span>Done</span> } @default { <span>Working…</span> } }
HTML
@let total = price() * qty();@defer (on viewport; prefetch on idle) { <app-chart [total]="total" />} @placeholder { <p>Chart</p>} @loading (minimum 300ms) { <p>Loading…</p>} @error { <p>Failed to load</p>}
HTML
@forrequirestrack: a unique id (item.id), $index for static lists. Implicit variables: $index, $first, $last, $even, $odd, $count. @switch never falls through.
@defer lazy-loads its standalone dependencies in a separate chunk. Triggers: on idle (the default), viewport, interaction, hover, immediate, timer(2s), or when cond.
@let declares a read-only template variable.
Classic: *ngIf="user as u; else login", *ngFor="let item of items; trackBy: byId", [ngSwitch]. Migrate with ng generate @angular/core:control-flow.
Directives & pipes
Kind
Does
Examples
Component
a directive with a template
<app-user-card>
Attribute directive
changes an element’s look or behavior
ngClass, ngStyle, ngModel, routerLink
Structural directive
adds or removes DOM through an <ng-template> (* is sugar for it)
@Pipe({ name: 'initials' }) // pure by defaultexport class InitialsPipe implements PipeTransform { transform(name: string, max = 2): string { return name.split(' ').slice(0, max).map((w) => w.charAt(0)).join('').toUpperCase(); }}// template: {{ user().name | initials: 3 }}
TypeScript
Pure pipes (the default) re-run only when an input changes by value (primitives) or by reference (objects); mutating an array doesn’t re-run them. Impure (pure: false) run on every change detection pass: async, json, keyvalue and slice are impure.
async subscribes to an Observable or Promise, returns the latest value, marks the view for check on each emission and unsubscribes on destroy: @if (user$ | async; as user) { ... }.
Reading a signal inside computed, effect or a template tracks it automatically; untracked(() => s()) reads without tracking.
Signals notify only when the new value differs (Object.is by default): set with the same mutated array does nothing, so update immutably.
effect is for side effects (logging, localStorage, non-Angular APIs). Deriving state in an effect by writing other signals is an anti-pattern: use computed or linkedSignal.
linkedSignal (stable since v20) is state that resets from a source but can be overridden. resource() and httpResource() (stable in v22) load async data into signals: value(), status(), error(), isLoading(), reload().
Change detection walks the tree top-down, re-evaluates bindings and patches the DOM where values changed.
Zone.js (classic) patches async browser APIs (events, timers, promises, XHR) and runs change detection after each one: simple, but too often. NgZone.runOutsideAngular() opts hot code out.
Zoneless (the default since v21; provideZonelessChangeDetection() in v20) runs only when Angular is notified: a signal read in a template changes, markForCheck() (the async pipe calls it), a template or host listener fires, ComponentRef.setInput(), or views are attached or removed. provideZoneChangeDetection() switches Zone.js back on.
OnPush (the default in v22): a component is checked only when an input binding gets a new value (a new reference for objects), an event is handled in it or its children, a signal it reads changes, or it is marked for check. Eager (formerly Default) is checked on every pass.
ChangeDetectorRef: markForCheck() marks the view and its ancestors dirty for the next pass; detectChanges() checks this view and its children synchronously now; detach() and reattach() take a view out of automatic checking.
In dev mode Angular runs a second verification pass and throws ExpressionChangedAfterItHasBeenCheckedError when a binding changed during the check. More detail: OnPush and signals.
Dependency injection
export const API_URL = new InjectionToken<string>('API_URL');@Injectable({ providedIn: 'root' }) // app-wide singleton, tree-shakableexport class UserApi { private http = inject(HttpClient); private base = inject(API_URL); get(id: string) { return this.http.get<User>(`${this.base}/users/${id}`); }}
Hierarchy: element injectors (a component or directive’s providers) → environment injectors (route providers, lazy routes, the root injector from bootstrapApplication) → platform → NullInjector, which throws NullInjectorError: No provider for X. The first match wins.
providedIn: 'root' gives one instance for the whole app. Listing a service in a component’s providers gives each component instance its own copy.
inject() works only in an injection context: constructors, field initializers, provider factories, guards, interceptors, resolvers, or runInInjectionContext(). Constructor injection (constructor(private api: UserApi) {}) still works.
Resolution modifiers: inject(Svc, { optional: true }) returns null if missing; self: true looks only in the local injector; skipSelf: true starts at the parent; host: true stops at the host component. Classic: @Optional(), @Self(), @SkipSelf(), @Host().
multi: true collects several providers into an array (HTTP_INTERCEPTORS, NG_VALUE_ACCESSOR). Newer versions also offer @Service(), a shorthand for @Injectable({ providedIn: 'root' }) that supports only inject(). More: Angular DI.
With withComponentInputBinding(), path params, query params and resolved data arrive as inputs: id = input.required<string>(), user = input.required<User>(). Classic: inject(ActivatedRoute).paramMap (an Observable) or .snapshot.
Guards: canActivate (enter a route), canActivateChild, canDeactivate (leave, e.g. unsaved changes), canMatch (whether the route matches at all, so a lazy chunk isn’t even downloaded). They return boolean, UrlTree or RedirectCommand (to redirect), or an Observable or Promise of these.
Functional guards and resolvers using inject() are the modern style; class guards (implements CanActivate) and canLoad are what older code uses.
Order matters: the first matching route wins, so ** goes last. pathMatch: 'full' is needed on empty-path redirects.
Typed forms (v14+): new FormControl('') is FormControl<string | null> because reset() sets null; use { nonNullable: true } or NonNullableFormBuilder. form.value omits disabled controls (so it’s Partial); getRawValue() includes them.
State: valid/invalid/pending/disabled, touched, dirty/pristine; valueChanges and statusChanges are Observables; updateOn: 'blur' delays updates; async validators return an Observable or Promise.
ControlValueAccessor makes a custom component work with formControlName and ngModel: implement writeValue, registerOnChange, registerOnTouched (and optionally setDisabledState), and provide it with { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => Rating), multi: true }.
Signal Forms (form() from @angular/forms/signals, bound with [formField]) are the newer signal-based API, introduced in v21. Reactive forms remain the safe interview answer.
HttpClient returns cold Observables: nothing is sent until you subscribe, each subscription sends a new request, and each completes after one response. Unsubscribing cancels a request in flight.
http.get<User>(url) only asserts the type; nothing validates the JSON. Status 0 means a network or CORS failure.
Requests are immutable: change them with req.clone(...). Interceptors run in the order listed. Class-based interceptors need withInterceptorsFromDi() plus the HTTP_INTERCEPTORS multi-provider.
HttpClient is now provided in root by default and uses fetch; provideHttpClient(...) adds interceptors and options. Errors: catchError, retry({ count: 2, delay: 1000 }).
XSRF: HttpClient copies the XSRF-TOKEN cookie into an X-XSRF-TOKEN header on same-origin requests other than GET and HEAD.
export class Search { private api = inject(UserApi); query = new FormControl('', { nonNullable: true }); results = toSignal( this.query.valueChanges.pipe(debounceTime(300), distinctUntilChanged(), switchMap((q) => this.api.search(q))), { initialValue: [] }, ); now = signal(Date.now()); constructor() { interval(60_000).pipe(takeUntilDestroyed()).subscribe(() => this.now.set(Date.now())); }}
TypeScript
Cleanup option
When
async pipe
Observables used in templates
toSignal(obs$)
unsubscribes when the component or injector is destroyed
takeUntilDestroyed()
in an injection context; elsewhere pass a DestroyRef
take(1), first()
you need only one value
subscription.unsubscribe() in ngOnDestroy
classic manual cleanup
Flattening: switchMap for search (cancels the previous request), exhaustMap for submit buttons (ignores clicks while busy), concatMap for ordered writes, mergeMap for parallel work. Cache a result with shareReplay(1); with { bufferSize: 1, refCount: true } the cache resets once every subscriber has left, which suits endless sources. See the RxJS operators sheet and switchMap vs mergeMap.
Performance, testing & security
Performance checklist: OnPush + signals; a stable track in every @for; lazy routes and @defer; pure pipes or computed instead of template method calls; NgOptimizedImage (<img ngSrc="..." width="400" height="300" priority>); CDK virtual scrolling; SSR with provideClientHydration() (plus withIncrementalHydration(), withEventReplay()); bundle budgets in angular.json; Angular DevTools profiler.
TestBed configures a test module: TestBed.configureTestingModule({ imports: [UserCard], providers: [...] }), then TestBed.createComponent(UserCard), fixture.componentRef.setInput('user', u), await fixture.whenStable() (or fixture.detectChanges()), and assert on fixture.nativeElement. Services: TestBed.inject(UserApi).
HTTP tests: provideHttpClient() and provideHttpClientTesting(), then HttpTestingController.expectOne(url).flush(data) and verify(). Routing tests: provideRouter(routes) with RouterTestingHarness.
Component harnesses (@angular/cdk/testing) query components through a stable API (loader.getHarness(MatButtonHarness)) instead of CSS selectors, so tests survive DOM changes. New CLI projects run unit tests with Vitest; Karma is still supported.
Security: Angular treats all values as untrusted. Interpolation escapes HTML; [innerHTML], [href] and [src] are sanitized (scripts stripped, javascript: URLs blocked). DomSanitizer.bypassSecurityTrustHtml() (and ...Url, ...ResourceUrl, ...Style, ...Script) disables that, so use it only on content you control. Writing nativeElement.innerHTML directly skips sanitization, and templates must never be built from user input.
CLI commands
Command
Does
ng new my-app
create a workspace and app
ng serve
dev server on localhost:4200 with live reload
ng generate component users/user-card (ng g c)
also service, directive, pipe, guard, interceptor, resolver, environments
ng build
production build into dist/ (production is the default configuration)
ng test
unit tests
ng add @angular/material
install a library and run its setup schematic
ng update @angular/core @angular/cli
upgrade, running the code migrations
ng g @angular/core:control-flow
*ngIf/*ngFor to @if/@for
ng g @angular/core:signals
@Input, @Output and queries to signal APIs (signal-input-migration, output-migration, signal-queries-migration)
ng g @angular/core:standalone, ng g @angular/core:inject
NgModules to standalone; constructor injection to inject()
ng extract-i18n
extract translatable messages
Quick answers
Standalone component? It declares its own template dependencies in imports, with no NgModule; the default since v19.
Constructor vs ngOnInit? Constructor for DI and field setup; ngOnInit runs after the first inputs are set.
input() vs @Input()?input() returns a read-only signal that works with computed and OnPush; @Input() is a plain property.
What does OnPush do? It checks a component only on new input references, its own events, signal changes or markForCheck().
What did Zone.js do? It patched async APIs to trigger change detection automatically; zoneless apps rely on signals and explicit notifications instead.
Signals vs Observables? Signals hold synchronous state; Observables model async streams. Convert with toSignal and toObservable.
computed vs effect?computed derives a value; effect runs side effects. Don’t use effects to copy state.
providedIn: 'root'? One tree-shakable, app-wide singleton, with no providers entry needed.
Pure vs impure pipe? Pure re-runs on input changes only; impure runs every change detection pass.
switchMap vs mergeMap?switchMap cancels the previous inner Observable; mergeMap runs them all concurrently.
Template-driven vs reactive forms? Directive-built async model vs an explicit, synchronous, testable model in the class.
How do you lazy load?loadComponent or loadChildren with a dynamic import(), plus @defer inside templates.
viewChild vs contentChild?viewChild queries the component’s own template; contentChild queries projected content.
AOT vs JIT? AOT compiles templates at build time (the default: smaller, faster, errors caught early); JIT compiles in the browser.
Why track in @for? So Angular can move DOM nodes instead of destroying and recreating them.
Gotchas & traps
ExpressionChangedAfterItHasBeenCheckedError: a bound value changed after it was checked, e.g. a child updates parent state in ngAfterViewInit. Derive the value with computed, set it earlier, or move the state into a signal or service; wrapping it in setTimeout only hides the problem.
Mutating OnPush inputs: this.items.push(x) keeps the same reference, so an OnPush child (and a pure pipe) never updates. Replace it: this.items = [...this.items, x] or items.update((xs) => [...xs, x]).
Memory leaks: subscriptions to interval, fromEvent, router events, valueChanges or a store that are never cleaned up. Use the async pipe, toSignal or takeUntilDestroyed. HTTP calls complete on their own.
@for without a good track: track $index on a reorderable list or track item when the API returns new objects recreates every row, losing focus, input state and animations.
Forgetting the parentheses: {{ count }} doesn’t read the signal; write count(). The compiler’s extended diagnostics warn about it.
inject() outside an injection context (inside setTimeout or a later method call) throws; call it in a field initializer or constructor.
form.value hides disabled controls; use getRawValue() to read them.
Effect loops: an effect that writes a signal it also reads can loop. Prefer computed or linkedSignal, or read with untracked.