Angular Change Detection, OnPush & Signals Demystified
How Angular decides when to update the DOM: the tree walk, what Zone.js did, what marks an OnPush component dirty, and how signals make zoneless apps possible.
Components, dependency injection, change detection, signals and the RxJS operators that come up in every Angular round.
How Angular decides when to update the DOM: the tree walk, what Zone.js did, what marks an OnPush component dirty, and how signals make zoneless apps possible.
The four RxJS flattening operators differ in one thing: what happens when a new value arrives while the previous inner observable is still running.
How Angular finds a dependency: element and environment injectors, providedIn root, provider recipes, InjectionToken, resolution modifiers and duplicate singletons.
Angular is a TypeScript-based framework from Google for building single-page applications. Unlike a view library, it's batteries-included: the router, forms, HttpClient, dependency injection, testing utilities, i18n, SSR and the CLI all ship as first-party packages. It's a complete rewrite of AngularJS (1.x), not an upgrade of it.
The main building blocks:
Templates are compiled ahead of time into efficient JavaScript instructions. Modern Angular is standalone-first: components declare their own imports, and the app starts with bootstrapApplication() instead of a root NgModule.
Likely follow-up: What is the difference between a framework and a library like React?
@Component decorator?easyA component is the basic UI building block: a TypeScript class that holds state and behavior, paired with a template that describes the DOM and optional styles. Angular creates an instance wherever its selector matches in a template.
The @Component metadata tells the compiler how to treat the class:
selector: how it's used in templates, usually an element name like app-user-card.template or templateUrl: the HTML.styles or styleUrl: CSS, scoped to this component by default.imports: the components, directives and pipes the template uses.providers, changeDetection, encapsulation and host bindings.In current Angular, components are standalone by default, so each one declares its own template dependencies instead of relying on an NgModule's declarations. Keep the class focused on presentation; data access and shared state belong in services.
selector, template, styles, imports are the core metadataimports lists template dependenciesLikely follow-up: What kinds of selectors can a component use?
Data flows between the class and the template in four ways:
{{ expr }}: one-way, class to view. Renders the value as text.[prop]="expr": one-way, class to view. Sets a DOM or component property, e.g. [disabled]="saving". Variants: [attr.aria-expanded] for attributes, [class.active] and [style.width.px].(event)="handler($event)": one-way, view to class, for DOM events and component outputs.[(value)]="x": sugar for a property binding plus a valueChange event binding, as used by [(ngModel)] or a model() input.A detail interviewers like: property binding targets DOM properties, not HTML attributes. Attributes only initialize properties, so an attribute with no matching property, such as data-*, is bound with [attr.name]. Template expressions should be cheap and side-effect free, because they're re-evaluated during change detection.
<h2>{{ title }}</h2>
<img [src]="avatarUrl" [alt]="name" />
<button
[attr.aria-expanded]="open"
[class.active]="open"
(click)="toggle()">
Menu
</button>
<input [(ngModel)]="query" />[()] is property binding plus a Change event[attr.x] for attributesLikely follow-up: What is the difference between an HTML attribute and a DOM property?
A directive is a class that adds behavior to elements in a template. There are three kinds:
ngClass, ngStyle, ngModel, or a custom appHighlight.*ngIf, *ngFor, *ngSwitchCase. The asterisk is sugar: Angular wraps the element in an <ng-template>, and the directive uses TemplateRef and ViewContainerRef to decide when to render it.Only one structural directive can sit on an element; nest them with <ng-container>. In modern templates, the built-in control flow blocks @if, @for and @switch replace the common structural directives: they're part of the template syntax, need no import, and NgIf/NgFor are now deprecated. Custom structural directives are still useful for things like permission checks.
ng-template* is sugar for <ng-template>; one per element@if/@for/@switch replace *ngIf/*ngFor/ngSwitchLikely follow-up: How would you write a custom structural directive?
Lifecycle hooks are methods Angular calls at specific moments. After the constructor, the order is:
ngOnChanges: before ngOnInit when the component has bound inputs, then every time an input changes. Receives SimpleChanges.ngOnInit: once, after the first inputs are set. Initialization logic goes here.ngDoCheck: on every change detection run, for custom change checks.ngAfterContentInit, then ngAfterContentChecked: projected content is initialized, then checked.ngAfterViewInit, then ngAfterViewChecked: the component's view and its child views are initialized (view queries resolved), then checked.ngOnDestroy: once, before the component is destroyed. Clean up here.On later passes only ngOnChanges (if an input changed), ngDoCheck and the two Checked hooks run again. Modern additions: inject(DestroyRef).onDestroy() registers cleanup from anywhere in an injection context, and afterNextRender()/afterEveryRender() are for DOM work and run only in the browser.
ngOnChanges, ngOnInit, ngDoCheckInit hooks run once; Checked hooks every passngOnDestroy or DestroyRef for cleanupafterNextRender for browser-only DOM workLikely follow-up: Why would ngOnChanges not fire when you mutate an input object?
ngOnInit?easyThe constructor is a plain TypeScript class feature. Angular calls it when it instantiates the component, before any inputs are bound. It's the place to inject dependencies and set simple defaults, and nothing that depends on inputs: reading a required input() there throws, and a classic @Input() still holds its default or undefined.
ngOnInit is an Angular lifecycle hook that runs once, after the first ngOnChanges, when input values are available. Initialization that depends on inputs belongs there, like starting a fetch based on an id input.
Rules of thumb:
ngAfterViewInit or afterNextRender() for DOM access.ngOnInit jobs become a computed() derived from inputs, or an effect() created in the constructor.ngOnInit: Angular hook, runs once after first inputsngOnInit for input-dependent setupDependency injection is a pattern where a class declares what it needs and the framework supplies it, instead of the class constructing its dependencies itself. In Angular you request a token (usually a class) with inject(UserService) or a constructor parameter, and an injector finds a provider that says how to create the value.
Providers are registered in a few places:
@Injectable({ providedIn: 'root' }) for an app-wide singleton.providers array of the ApplicationConfig, a route, or a component.Injectors form a hierarchy. Angular first walks the element injectors up the component tree, then the environment injectors (route, root, platform). The first provider found wins, and its injector owns the instance. If nothing is found, you get a no-provider error unless the dependency was marked optional.
The payoff is loose coupling, easy testing (swap a real service for a fake in TestBed), and control over each dependency's scope and lifetime.
providedIn, app config, route or component providersLikely follow-up: What does providedIn: 'root' do? · How can the same service end up with two instances?
providedIn: 'root' do?easyA service is a class for logic that doesn't belong to one component's view: fetching data, shared state, logging, auth. It's marked with @Injectable() so Angular can create it and inject its own dependencies.
@Injectable({ providedIn: 'root' }) registers the service with the root environment injector, which gives you:
providers array is always bundled."Singleton" really means one instance per injector. If you also list the service in a component's providers, that component and its children get their own separate instance, and a route's providers does the same for that route's subtree. That's handy for deliberately scoped state, but it's also the usual cause of "my service state keeps resetting".
providedIn: 'root': one lazy, tree-shakable app-wide instanceproviders create extra instanceshttp.get() sends two requests.HttpClient aborts the request.of(1)) or asynchronously.map, switchMap, retry and debounceTime.Angular uses Observables for HttpClient, router events and form valueChanges. To bridge the two, firstValueFrom() and lastValueFrom() turn an Observable into a Promise, and from(promise) goes the other way.
firstValueFrom bridges to PromisesLikely follow-up: What is the difference between a hot and a cold Observable?
Change detection keeps the DOM in sync with component state. Each compiled template has an update function that re-evaluates its bindings, compares each result with the previous value (by identity, not deep equality), and touches only the DOM that changed. A pass walks the component tree top-down from the root, so parents are checked before their children.
Two questions matter: when a pass runs, and which components it checks.
ApplicationRef.tick() after each task. Zoneless apps, the default in recent versions, schedule a pass only when Angular is notified: a signal read in a template changes, a template listener fires, markForCheck() is called (the async pipe does this), or an input is set.OnPush components are checked only when marked dirty, and signals let Angular refresh just the views that read the changed signal.In development mode Angular runs an extra check and throws NG0100 (ExpressionChangedAfterItHasBeenChecked) if a binding changed in between.
Likely follow-up: What does OnPush change? · What triggers change detection in a zoneless app?
It depends on how the components are related:
input() (classic @Input()) and the parent binds [todo]="item".output() (classic @Output() with an EventEmitter) and calls emit(); the parent listens with (toggled)="onToggle($event)".model() enables [(value)] two-way binding.viewChild(TodoItem), or a template reference variable, gives the parent the child instance.BehaviorSubject, provided at the right level.Content projection with <ng-content> is the other composition tool: the parent passes markup instead of data. When lots of state is shared across a large app, a store (NgRx or a signal-based store) formalizes the service approach.
input() or @Input()output() or @Output() eventsviewChild() or a template refLikely follow-up: When would you reach for a state management library instead of a service?
Pipes transform values for display in templates: {{ price | currency:'EUR' }}, {{ createdAt | date:'short' }}. Built-ins include date, currency, decimal, percent, uppercase, titlecase, slice, json, keyvalue and async.
transform only when the input value or an argument changes: a different primitive or a new object reference. Mutating an array in place doesn't re-run them. That makes them cheap, effectively memoized per binding.pure: false) run on every change detection pass, so they must be fast. async, json and keyvalue are impure because their output can change while the input reference stays the same.A custom pipe is a class decorated with @Pipe({ name }) that implements PipeTransform. Pipes are standalone by default, so you add them to a component's imports. Prefer a pure pipe (or a computed() signal) over calling a method in the template, which re-runs on every check.
@Pipe({ name: 'truncate' })
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 20): string {
return value.length > limit ? value.slice(0, limit) + '…' : value;
}
}
// In a component with imports: [TruncatePipe]
// {{ post.title | truncate:40 }}|async, json)@Pipe + PipeTransform, then add to importsLikely follow-up: Why does a pure pipe not update when you push to an array?
NgModules were Angular's original unit of compilation. A module listed components in declarations, pulled in dependencies through imports, shared things with exports and registered providers. A component could only use what its module made available, which led to lots of indirection and "shared modules".
A standalone component lists its own template dependencies in its imports array, so it's self-contained. It's the default in current Angular, and the rest of the app follows the same style:
bootstrapApplication(App, appConfig) instead of bootstrapModule(AppModule).provideRouter(routes) and provideHttpClient() instead of RouterModule.forRoot() and HttpClientModule.loadComponent.The benefits are less boilerplate, dependencies visible in one place, and simpler lazy loading and testing. The two styles interoperate: a standalone component can import an NgModule, and an NgModule can import a standalone component. The CLI ships a migration: ng generate @angular/core:standalone.
importsbootstrapApplication and provide*() functions replace root modulesOnPush change detection strategy do, and when does an OnPush component update?midWith ChangeDetectionStrategy.OnPush, Angular checks a component only when its view has been marked dirty. Otherwise the traversal skips it and, unless something inside was flagged, its whole subtree. A component is marked dirty when:
setInput()),async pipe receives a value,markForCheck() is called,What does not update it: mutating an input object in place, or assigning a plain field inside a setTimeout, a promise or a subscribe callback. That's why OnPush code uses immutable updates (spread, map, filter) and signals or the async pipe for async data.
The payoff is fewer checks in large trees and a more predictable data flow. In the newest Angular versions OnPush is the default when changeDetection isn't set, and the check-always strategy is named Eager (previously Default).
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h3>{{ user().name }}</h3>`,
})
export class UserCard {
readonly user = input.required<User>();
}
// In the parent, bound as [user]="user":
this.user.name = 'Grace'; // same reference: card stays stale
this.user = { ...this.user, name: 'Grace' }; // new reference: card updatesasync pipe, signalLikely follow-up: How do you update an OnPush component from a setTimeout callback?
switchMap, mergeMap, concatMap and exhaustMap?midAll four map each source value to an inner Observable, subscribe to it and flatten its values into one stream. They differ only in what happens when a new value arrives while the previous inner Observable is still running:
switchMap cancels the previous inner and switches to the new one. Use it when only the latest result matters: typeahead search, loading details for the current route param.mergeMap runs inners concurrently, optionally with a concurrency limit. Use it for independent parallel work where order doesn't matter.concatMap queues values and runs inners one at a time, in order. Use it for sequential writes such as autosave.exhaustMap ignores new values until the current inner completes. Use it to prevent double submits on a login or save button.Two points that impress interviewers: don't use switchMap for writes, because unsubscribing doesn't undo a request the server already received; and put catchError on the inner Observable, so one failed request doesn't kill the outer stream.
results$ = this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
);
saved$ = this.drafts$.pipe(concatMap((draft) => this.api.save(draft)));
loggedIn$ = this.submit$.pipe(exhaustMap(() => this.auth.login(this.form.getRawValue())));switchMap cancels the previous inner: typeahead, route paramsmergeMap runs in parallel; concatMap queues in orderexhaustMap ignores new values while busy: double submitsswitchMap for writes; catch errors inside the innerLikely follow-up: What happens to an HTTP request when switchMap unsubscribes from it?
Subject, BehaviorSubject, ReplaySubject and AsyncSubject?midAll four are both an Observable and an Observer, and they're multicast: every subscriber shares the same execution. The difference is what a late subscriber receives:
Subject: no initial value and no memory. Subscribers only get values emitted after they subscribe.BehaviorSubject: requires an initial value and always holds the current one. A new subscriber immediately gets the latest value, and you can read it synchronously with .value or getValue(). The classic choice for state in services.ReplaySubject(bufferSize, windowTime): no initial value; replays the last N values (optionally only those within a time window) to each new subscriber.AsyncSubject: emits only its last value, and only when it completes. Subscribers that arrive after completion get that value too, a bit like a Promise.Expose subjects from services through asObservable() so consumers can't call next(). In modern Angular, a signal() often replaces a BehaviorSubject for synchronous state.
const s = new Subject<number>();
s.next(1);
s.subscribe(console.log); // logs 2 only: 1 was emitted before subscribing
s.next(2);
const b = new BehaviorSubject(0);
b.next(1);
b.subscribe(console.log); // logs 1 immediately, then 2
b.next(2);
const r = new ReplaySubject<number>(2);
r.next(1); r.next(2); r.next(3);
r.subscribe(console.log); // logs 2, 3Subject: late subscribers get only future valuesBehaviorSubject: initial value, replays current value, .valueReplaySubject: replays the last N valuesAsyncSubject: emits the last value on completionasObservable()Likely follow-up: When would you use a signal instead of a BehaviorSubject?
You define a Routes array and register it with provideRouter(routes) in the application config (classic apps use RouterModule.forRoot(routes)). Each route maps a path to a component or to a lazy loadComponent/loadChildren, and can have children, redirectTo, guards, resolvers and data.
In templates:
<router-outlet /> marks where the routed component renders.routerLink="/users" or [routerLink]="['/users', id]" navigates without a full page reload, and routerLinkActive adds a class to the active link.In code, inject(Router).navigate(['/users', id]) navigates, and ActivatedRoute exposes paramMap, queryParamMap and data, as Observables or through snapshot. With withComponentInputBinding(), params, query params and resolved data are bound straight to component inputs.
Order matters: the router takes the first route that matches, so put specific paths before general ones and the ** wildcard last. An empty-path redirect needs pathMatch: 'full'.
export const routes: Routes = [
{ path: '', redirectTo: 'users', pathMatch: 'full' },
{ path: 'users', component: UserList },
{ path: 'users/:id', component: UserDetail },
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES) },
{ path: '**', component: NotFound },
];
bootstrapApplication(App, {
providers: [provideRouter(routes, withComponentInputBinding())],
});Routes array registered with provideRouter()<router-outlet> renders; routerLink navigatesActivatedRoute or input binding for params** goes lastLikely follow-up: What is the difference between paramMap and snapshot.paramMap?
Both are built on the same model classes (FormControl, FormGroup), but they differ in where the form model lives.
FormsModule): you describe the form in the template with ngModel and validation attributes such as required and minlength, and directives create the controls implicitly. The data model is mutable and bound two-way, and setup is asynchronous because the controls are created as the view renders. Good for simple forms: login, a search box, a settings toggle.ReactiveFormsModule): you build the model explicitly in the class with FormGroup, FormControl, FormArray or FormBuilder, and bind it with [formGroup] and formControlName. Access is synchronous, values are strongly typed, valueChanges and statusChanges are Observables, and validators are plain functions. That makes them easier to unit test and better for dynamic or complex forms.Most teams default to reactive forms for anything non-trivial. Recent Angular versions also add Signal Forms, a signal-based API in @angular/forms/signals.
ngModel, model implicit in the template[formGroup]Likely follow-up: How do you add a field dynamically in a reactive form?
Guards are functions the router runs to decide whether a navigation may proceed. Modern Angular uses functional guards, which can call inject():
canActivate: may the user enter this route? (auth, roles)canActivateChild: the same check for every child route.canDeactivate: may the user leave? Typically "you have unsaved changes".canMatch: may this route definition be used at all? If it returns false, the router tries the next matching route, and a lazy route's code isn't downloaded. It replaces the deprecated canLoad.A guard returns true/false, a UrlTree or RedirectCommand to redirect, or an Observable or Promise of one of those. Returning a UrlTree is better than calling router.navigate() inside the guard, because the router handles the redirect as part of the same navigation.
Class-based guards implementing CanActivate still work, but functions are simpler and composable. And guards are a UX feature, not security: the server must still enforce authorization.
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
return auth.isLoggedIn() || inject(Router).createUrlTree(['/login']);
};
export const unsavedChangesGuard: CanDeactivateFn<EditPage> = (page) =>
!page.dirty() || confirm('Discard unsaved changes?');
export const routes: Routes = [
{ path: 'settings', component: EditPage, canActivate: [authGuard], canDeactivate: [unsavedChangesGuard] },
];canActivate, canActivateChild, canDeactivate, canMatchinject() for dependenciesUrlTree/RedirectCommand, or async versionscanMatch replaces canLoad and skips lazy downloadsLikely follow-up: In what order do guards and resolvers run?
Lazy loading splits the app into chunks that are downloaded only when needed, which shrinks the initial bundle and speeds up the first load. The build creates a separate chunk for every dynamic import().
For routes:
loadComponent: () => import('./settings/settings').then((m) => m.Settings) lazy loads one standalone component. If the file uses a default export, () => import('./settings/settings') is enough.loadChildren: () => import('./admin/admin.routes').then((m) => m.ADMIN_ROUTES) lazy loads a whole set of child routes (in NgModule apps, a routing module).Related tools:
withPreloading(PreloadAllModules) downloads lazy chunks in the background after the app starts, so later navigations are instant.canMatch guards keep a chunk from being downloaded for users who can't use it.providers give a lazy section its own environment injector and scoped services.@defer lazy loads parts of a template rather than whole routes.Don't statically import a lazy component elsewhere, or it ends up back in the main bundle.
import() creates separate chunksloadComponent for one component, loadChildren for route setscanMatch prevents downloading unauthorized chunks@defer lazy loads template sectionsLikely follow-up: How would you write a custom preloading strategy?
signal, computed and effect.midA signal is a reactive wrapper around a value that notifies its consumers when the value changes. You read it by calling it, count(), which lets Angular track who depends on it.
signal(initial) creates a writable signal, changed with set() or update(fn). asReadonly() exposes a read-only view of it.computed(() => ...) derives a read-only value. It's lazy and memoized: it recalculates only after a signal it read changes, and it tracks dependencies dynamically.effect(() => ...) runs a side effect whenever the signals it read change. It runs at least once, runs asynchronously as part of change detection, needs an injection context, and is destroyed with the component or service that created it. Use it to sync with the outside world (storage, logging, a chart library), not to copy one signal into another; that's a computed().Signals compare with Object.is by default, so update immutably. Templates are reactive consumers too: a signal change refreshes only the views that read it, which makes OnPush and zoneless change detection practical. untracked() reads a signal without tracking it.
export class Cart {
readonly items = signal<Item[]>([]);
readonly total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
constructor() {
effect(() => localStorage.setItem('cart', JSON.stringify(this.items())));
}
add(item: Item) {
this.items.update((list) => [...list, item]); // new array, not push()
}
}signal() holds writable state; read it by calling itcomputed() is lazy, memoized and read-onlyeffect() is for side effects, not for deriving stateObject.is equality by default: update immutablyLikely follow-up: How do signals differ from RxJS Observables? · What is linkedSignal for?
An interceptor is middleware for HttpClient: every request passes through the chain on the way out, and every response on the way back. Typical uses are auth headers, logging, global error handling, retries, caching and loading indicators.
In modern Angular an interceptor is a function of type HttpInterceptorFn. It receives the request and a next handler and returns next(req), optionally transformed with RxJS operators. It runs in an injection context, so it can call inject() (at the top, not inside async callbacks). Register it with provideHttpClient(withInterceptors([...])).
Key details:
req.clone({ setHeaders: {...} }) to change one.HttpContext tokens pass per-request flags, such as "skip auth for this call".Class-based interceptors (implementing HttpInterceptor, registered with the HTTP_INTERCEPTORS multi-provider) still work, but need withInterceptorsFromDi().
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).token();
const router = inject(Router);
const authReq = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
return next(authReq).pipe(
catchError((err: HttpErrorResponse) => {
if (err.status === 401) router.navigate(['/login']);
return throwError(() => err);
}),
);
};
// app.config.ts: provideHttpClient(withInterceptors([authInterceptor]))HttpClient request and responseHttpInterceptorFn registered via withInterceptorsreq.clone()Likely follow-up: How would you retry failed requests or refresh an expired token in an interceptor?
A subscription to a long-lived Observable keeps running after the component is destroyed: its callback still fires and holds a reference to the component, so it can't be garbage collected. Typical culprits are interval, fromEvent, subjects in root services, store selectors, router events and valueChanges. HTTP calls complete after one response, so they leak less, but cancelling them on destroy still avoids work for a dead component.
Options, most preferred first:
async pipe and toSignal() both unsubscribe automatically.takeUntilDestroyed() from @angular/core/rxjs-interop completes the stream when the component, directive or service is destroyed. It needs an injection context; in ngOnInit or later, pass it a DestroyRef.take(1) or first() when you only need one value.destroy$ Subject used with takeUntil(this.destroy$) and completed in ngOnDestroy, or keeping the Subscription and calling unsubscribe().Put takeUntil or takeUntilDestroyed last in the pipe, so no later operator keeps an inner subscription alive.
export class Dashboard implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
constructor() {
interval(5000).pipe(takeUntilDestroyed()).subscribe(() => this.refresh());
}
ngOnInit() {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) // not an injection context: pass DestroyRef
.subscribe((params) => this.load(params.get('id')));
}
}async pipe or toSignal(): automatic cleanuptakeUntilDestroyed() needs an injection context or DestroyReftakeUntil(destroy$) last in the pipeLikely follow-up: Do you need to unsubscribe from HttpClient calls?
async pipe do, and why is it preferred over subscribing manually?easyThe async pipe subscribes to an Observable (or a Promise) from the template and returns its latest value. It:
OnPush components.null until the first value arrives.It's preferred because there's less boilerplate, no manual subscription management, and the data stays a declarative stream instead of being copied into mutable fields.
The main pitfall: each | async creates its own subscription. Writing user$ | async three times against a cold HTTP Observable sends three requests. Subscribe once with @if (user$ | async; as user) or @let user = user$ | async;, or share the stream with shareReplay. In signal-based code, toSignal() does the same job from the class.
markForCheck(), so it works with OnPush| async is a separate subscription: subscribe onceThe CLI build uses main.ts as the entry point, and index.html contains the root element, such as <app-root>.
In a standalone app, main.ts calls bootstrapApplication(App, appConfig). Angular then:
appConfig (provideRouter, provideHttpClient and so on); providedIn: 'root' services are created on demand later,provideAppInitializer() and waits for any async ones,In NgModule apps, main.ts calls platformBrowser().bootstrapModule(AppModule) (older code uses platformBrowserDynamic()), and AppModule lists the root component in its bootstrap array.
With SSR, the server renders the same app to HTML first, and the browser hydrates that markup instead of rendering from scratch.
main.ts calls bootstrapApplication(App, appConfig)appConfig providersbootstrapModule(AppModule)@if, @for, @switch), and how does it differ from *ngIf and *ngFor?easyBuilt-in control flow is template syntax for conditionals and loops that replaces the structural directives:
@if (cond) { } @else if (other) { } @else { }, with as to alias the value, e.g. @if (user$ | async; as user).@for (item of items; track item.id) { } @empty { }, with the implicit variables $index, $count, $first, $last, $even and $odd.@switch (status) { @case ('active') { } @default { } }.How it differs from *ngIf, *ngFor and ngSwitch:
track is required in @for, which forces you to think about item identity; the old trackBy was optional and often forgotten.@empty and @else if are built in, and type narrowing works better inside @if.<ng-template> or <ng-container> wrappers are needed.NgIf, NgFor and NgSwitch are deprecated, and the CLI can migrate templates with ng generate @angular/core:control-flow.
@if (loading()) {
<app-spinner />
} @else {
@for (user of users(); track user.id; let i = $index) {
<p>{{ i + 1 }}. {{ user.name }}</p>
} @empty {
<p>No users found.</p>
}
}@if, @for, @switch are built into the template syntaxtrack is required in @for; @empty is built inNgIf/NgFor/NgSwitch are deprecated; CLI migration existsLikely follow-up: What should you pass to track, and what happens if you track by $index?
input(), output() and model(), and how do they compare with @Input() and @Output()?easyThey're the signal-based way to declare a component's public API.
input<T>(defaultValue) and input.required<T>() return a read-only InputSignal. You read it as this.name() in the class and name() in the template, and derive from it with computed() instead of ngOnChanges. Options include alias and transform, e.g. transform: booleanAttribute.output<T>() returns an OutputEmitterRef: call this.rated.emit(value), and the parent listens with (rated)="...". Outputs don't bubble like DOM events.model<T>() is a writable input signal for two-way binding. When the component calls set() or update(), Angular emits a matching <name>Change event, so the parent can write [(value)]="score".The classic equivalents are @Input() name!: string, with ngOnChanges or a setter to react to changes, and @Output() rated = new EventEmitter<number>(). The new APIs add compile-time checking of required inputs, better type inference and built-in reactivity through computed and effect, and they fit naturally with OnPush and zoneless change detection.
@Component({
selector: 'app-rating',
template: `
@for (star of stars; track star) {
<button [disabled]="disabled()" (click)="value.set(star); rated.emit(star)">★</button>
}
`,
})
export class Rating {
readonly value = model(0); // parent: [(value)]="score"
readonly disabled = input(false, { transform: booleanAttribute });
readonly rated = output<number>();
protected readonly stars = [1, 2, 3, 4, 5];
}input() returns a read-only signal; input.required() for mandatoryoutput() emits events with .emit(); no bubblingmodel() is a writable input enabling [(x)]ngOnChanges with computed() on inputsLikely follow-up: What does an input transform do?
<ng-content> work?midContent projection lets a parent pass markup into a child component, much like children in React. The child's template marks where it goes with <ng-content>.
<ng-content /> renders everything the parent put between the child's tags.<ng-content select="[card-title]" /> takes only matching content (a CSS selector on element, attribute or class), and a plain <ng-content /> catches the rest. ngProjectAs lets content match a different selector.<ng-content> renders when nothing is projected into that slot.A key detail: projected content is created and owned by the parent. It's instantiated even if the child hides the <ng-content>, so wrapping it in @if doesn't prevent creation, and the same content can't be projected twice. For conditional, lazy or repeated rendering, the parent passes an <ng-template> and the child renders it with ngTemplateOutlet.
The child reaches projected components with contentChild()/contentChildren() (classic @ContentChild), ready by ngAfterContentInit.
@Component({
selector: 'app-card',
template: `
<header><ng-content select="[card-title]" /></header>
<section><ng-content /></section>
<footer><ng-content select="app-card-actions">No actions</ng-content></footer>
`,
})
export class Card {}
// <app-card>
// <h2 card-title>Invoice #42</h2>
// <p>Due next week.</p>
// </app-card><ng-content> is a slot for markup passed by the parentselect creates named slots; default slot catches the restng-template + ngTemplateOutlet for conditional or repeated contentLikely follow-up: How do you access a projected component from the child?
<ng-template>, <ng-container> and <ng-content>?mid<ng-template> defines a template fragment that is not rendered on its own. Something has to instantiate it: a structural directive, ngTemplateOutlet, the else of *ngIf, or ViewContainerRef.createEmbeddedView(). It can declare context variables with let-, so one fragment can render with different data, and a template reference such as #row gives you its TemplateRef.<ng-container> is a grouping element that renders no DOM node. Use it to apply a structural directive without a wrapper <div>, to nest structural directives (only one is allowed per element), or as the host for *ngTemplateOutlet.<ng-content> is a projection slot in a child component's template, where the parent's markup is inserted.A common pattern combines them: a reusable list or table component accepts a TemplateRef (as an input, or through contentChild(TemplateRef)) so the parent decides how each item looks, and the component renders it with ngTemplateOutlet plus a context object.
<ng-template #userRow let-user let-i="index">
<li>{{ i + 1 }}. {{ user.name }}</li>
</ng-template>
<ul>
@for (u of users(); track u.id; let i = $index) {
<ng-container *ngTemplateOutlet="userRow; context: { $implicit: u, index: i }" />
}
</ul>ng-template: a fragment rendered only when instantiatedlet- variables receive values from the context objectng-container: grouping without an extra DOM elementng-content: slot for projected parent markupViewChild and ContentChild, and when are their results available?midBoth are queries that give a component a reference to something rendered in its tree. They differ in where they look:
viewChild(), viewChildren(), classic @ViewChild/@ViewChildren) search the component's own template.contentChild(), contentChildren(), classic @ContentChild/@ContentChildren) search the projected content the parent passed in through <ng-content>.You can query by template reference name ('box'), by component or directive class, or by token, and pick what you get back with read (ElementRef, TemplateRef, ViewContainerRef).
Timing: with decorators, view results are ready in ngAfterViewInit and content results in ngAfterContentInit, or already in ngOnInit with static: true if the element isn't inside a conditional block. The plural forms return a QueryList with a changes Observable. Signal queries return signals that update automatically, so you read them in computed(), effect() or afterNextRender() without worrying about hook timing, and viewChild.required() removes the undefined case.
@Component({
selector: 'app-search',
template: `<input #box /><ng-content />`,
})
export class Search {
readonly box = viewChild.required<ElementRef<HTMLInputElement>>('box');
readonly tabs = contentChildren(Tab); // projected <app-tab> components
constructor() {
afterNextRender(() => this.box().nativeElement.focus());
}
}ngAfterViewInit / ngAfterContentInitviewChild()) update automaticallyread chooses ElementRef, TemplateRef or ViewContainerRefAngular templates aren't run as HTML. The Angular compiler turns each template into JavaScript instructions that create and update the DOM. The question is when that happens.
@angular/compiler) ships with the app, and templates are compiled as the app starts.AOT advantages:
strictTemplates, type errors in templates fail the build instead of the user's browser.Since the Ivy compiler became standard, AOT is the default for both ng build and ng serve, so JIT is mostly a legacy concern today.
Create a class with @Directive and an attribute selector such as [appHighlight]. Like components, directives are standalone by default, so a component adds it to imports to use it.
appHighlight="lightblue".host metadata, which angular.dev recommends over the classic @HostListener and @HostBinding decorators.ElementRef and use Renderer2 rather than manipulating nativeElement yourself; it's safer with SSR.A structural directive is built differently: it injects TemplateRef and ViewContainerRef, and calls createEmbeddedView() or clear() to add or remove the template, for example an *appHasRole="'admin'" directive. Directives can also be composed onto components with hostDirectives.
@Directive({
selector: '[appHighlight]',
host: {
'(mouseenter)': 'hovered.set(true)',
'(mouseleave)': 'hovered.set(false)',
'[style.backgroundColor]': 'background()',
},
})
export class Highlight {
readonly color = input('', { alias: 'appHighlight' });
protected readonly hovered = signal(false);
protected readonly background = computed(() => (this.hovered() ? this.color() || 'yellow' : null));
}
// <p appHighlight="lightblue">Hover me</p>@Directive with an attribute selector like [appHighlight]host bindings and listeners instead of direct DOM accessTemplateRef + ViewContainerRefLikely follow-up: What are hostDirectives used for?
Operators are pure functions chained with pipe(). Each returns a new Observable and leaves the source untouched. The everyday set:
map changes each value; scan accumulates state, like reduce over time.filter drops values; take(n) and first() complete after n values or the first match; distinctUntilChanged suppresses consecutive duplicates.debounceTime(ms) emits only after the source has been quiet for that long; throttleTime limits the rate.tap for logging or setting a loading flag without changing the value; finalize runs on complete, error or unsubscribe.switchMap, mergeMap, concatMap, exhaustMap map values to inner Observables such as HTTP calls.combineLatest, forkJoin, merge, withLatestFrom, startWith.catchError to recover, retry to resubscribe.shareReplay to multicast and cache.The typeahead search is the classic example that uses most of them together.
results$ = this.search.valueChanges.pipe(
map((q) => q.trim()),
filter((q) => q.length >= 2),
debounceTime(300),
distinctUntilChanged(),
tap(() => this.loading.set(true)),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
tap(() => this.loading.set(false)),
);pipe()map, filter, tap for transform, filter, side effectsdebounceTime + distinctUntilChanged for user inputswitchMap family for HTTP; catchError/retry for errorsLikely follow-up: What is the difference between debounceTime and throttleTime?
The difference is where the producer lives.
subscribe, so every subscriber gets its own, independent execution from the beginning. HttpClient requests, of, timer, interval and defer are cold.Why it matters:
http.get(), for example two | async pipes, sends two requests.To turn a cold Observable into a shared (multicast) one, use share(), where late subscribers only get future values, or shareReplay({ bufferSize: 1, refCount: true }), which also replays the latest value to late subscribers. That's the usual way to cache an HTTP result that several components read. A BehaviorSubject or a signal in a service is the other common way to share state.
share() / shareReplay() multicast a cold sourceforkJoin and combineLatest?midBoth combine several Observables into one, but they emit at different times.
forkJoin waits for every source to complete, then emits once with the last value of each (as an array or an object) and completes. It's the RxJS version of Promise.all, ideal for parallel HTTP calls. If any source errors, it errors; if any source completes without emitting, it completes without emitting anything. Don't use it with streams that never complete, such as valueChanges.combineLatest emits every time any source emits, once each source has emitted at least once, with the latest value from each. It's for derived state from long-lived streams, such as data plus filters. Seed slow sources with startWith if you need an early first emission.Related operators: zip pairs values by index, withLatestFrom samples other streams only when the main one emits, and merge interleaves values without combining them. In signal-based code, computed() often replaces combineLatest for synchronous state.
forkJoin: one emission after all sources completeforkJoin is Promise.all for Observables: parallel HTTPcombineLatest: emits on every change once all have emittedforkJoin never emits for never-completing streamsLikely follow-up: When would you use withLatestFrom instead of combineLatest?
A synchronous validator is a ValidatorFn: a function that takes an AbstractControl and returns null when valid, or an errors object such as { forbiddenValue: true }. Wrap it in a factory function when it needs parameters. For cross-field rules like "passwords match", put the validator on the FormGroup and read the child controls.
An async validator (AsyncValidatorFn) returns an Observable or Promise of ValidationErrors | null, typically from an HTTP call such as "is this username taken?". Details worth knowing:
PENDING.first().updateOn: 'blur', or debounce inside the validator, to avoid a request per keystroke.Show errors with control.hasError('taken') or control.errors?.['taken']. In template-driven forms, the same logic is wrapped in a directive that provides NG_VALIDATORS or NG_ASYNC_VALIDATORS.
export const forbiddenValue = (bad: string): ValidatorFn => (control) =>
control.value === bad ? { forbiddenValue: { bad } } : null;
export const passwordsMatch: ValidatorFn = (group) =>
group.get('password')?.value === group.get('confirm')?.value ? null : { mismatch: true };
export const usernameAvailable = (api: UserApi): AsyncValidatorFn => (control) =>
api.isTaken(control.value).pipe(map((taken) => (taken ? { taken: true } : null)));
username = new FormControl('', {
validators: [Validators.required, forbiddenValue('admin')],
asyncValidators: [usernameAvailable(inject(UserApi))],
updateOn: 'blur',
});ValidatorFn returns null or an errors objectFormGroupPENDINGupdateOn: 'blur'useClass, useValue, useFactory, useExisting), and when do you need an InjectionToken?hardA provider tells an injector how to produce the value for a token:
useClass: instantiate a class, possibly a different one than the token, e.g. { provide: Logger, useClass: ConsoleLogger }. A bare providers: [Logger] is shorthand for this.useValue: supply a ready-made value, such as a config object or a test double.useFactory: call a function when the token is first requested. The factory can call inject() (classic code lists its dependencies in deps), which makes it right for values computed from the environment.useExisting: an alias that points one token at another, so both return the same instance. useClass here would create a second one.multi: true: several providers add to one token, and injecting it returns an array, as with NG_VALIDATORS.An InjectionToken is needed for anything that isn't a class, because TypeScript interfaces and types disappear at runtime. new InjectionToken<AppConfig>('APP_CONFIG') gives you a typed, unique token; the string is only a debug label. A token can also carry a tree-shakable default with { providedIn: 'root', factory: () => ... }.
export const API_URL = new InjectionToken<string>('API_URL', {
providedIn: 'root',
factory: () => '/api', // default when nobody provides it
});
providers: [
{ provide: Logger, useClass: environment.production ? RemoteLogger : ConsoleLogger },
{ provide: API_URL, useValue: 'https://api.example.com' },
{ provide: STORAGE, useFactory: () => (isPlatformBrowser(inject(PLATFORM_ID)) ? localStorage : memoryStorage) },
{ provide: AuditLogger, useExisting: Logger },
]
const apiUrl = inject(API_URL); // typed as stringuseClass instantiates, useValue supplies a valueuseFactory computes the value and can call inject()useExisting aliases the same instanceInjectionToken for non-class values like configmulti: true collects several providers into an arrayAngular has two injector trees:
providers (or viewProviders) gets one on its host element.providedIn: 'root' plus the providers passed to bootstrapApplication), child injectors for routes with providers and lazy-loaded routes, then the platform injector, and finally the NullInjector, which throws.When a component injects a token, Angular walks up the element injectors from the requesting element, then up the environment injectors. The first provider found wins, and that injector owns the instance.
Consequences:
viewProviders hides the service from projected content.optional returns null instead of throwing, self looks only locally, skipSelf starts at the parent, and host stops at the host component.NullInjectoroptional, self, skipSelf, hostLikely follow-up: What is the difference between providers and viewProviders?
inject() function, how does it differ from constructor injection, and what is an injection context?hardinject(Token) resolves a dependency from the current injector, exactly like a constructor parameter, but as a function call. Both go through the same injectors, so the result is the same; the differences are about where you can use it.
inject() only works inside an injection context:
useFactory or an InjectionToken factory,runInInjectionContext(injector, fn).Calling it anywhere else, such as in a click handler, ngOnInit or a setTimeout, throws NG0203. Inject in a field and use the value later.
Why it's now preferred:
super().inject(Logger, { optional: true }) has the type Logger | null.inject() internally.runInInjectionContext for code outside those contextsChangeDetectorRef, and what is the difference between markForCheck() and detectChanges()?hardChangeDetectorRef gives a component manual control over its own change detection. Its main methods:
markForCheck() marks this view and all its ancestors dirty, so they're checked in the next pass. It doesn't run a check itself; in a zoneless app it also schedules that next pass. It's the fix when an OnPush component assigns a plain field in a callback Angular doesn't know about. The async pipe calls it for you.detectChanges() checks this view and its children synchronously, right now. Use it with detach(), or in tests. Reaching for it to silence NG0100 errors is usually a sign of broken data flow.detach() removes the view from the tree so passes skip it; reattach() puts it back. That suits widgets that refresh on their own schedule, like a high-frequency ticker.checkNoChanges() is the dev-mode verification check.In modern code you rarely need any of these: signals, the async pipe and toSignal() notify Angular automatically. Turning that plain field into a signal removes the need for markForCheck().
markForCheck(): marks view and ancestors for the next passdetectChanges(): checks this subtree synchronously nowdetach()/reattach() take a view out of normal checkingasync pipe make manual calls rare[( )] syntax?midThe "banana in a box" syntax is pure sugar. [(size)]="fontSize" compiles to a property binding plus an event binding:
[size]="fontSize" (sizeChange)="fontSize = $event"
So any component with an input x and an output named xChange supports two-way binding. The data flow is still one-way in each direction: the value goes down through the input, and changes come back up through the event.
model() declares both at once. size = model(16) is a writable signal; calling this.size.set(20) updates it locally and emits sizeChange. The parent can bind a plain field or a writable signal, passing the signal itself without calling it: [(size)]="fontSize".@Input() size plus @Output() sizeChange = new EventEmitter<number>(), emitting manually.[(ngModel)] follows the same pattern: [ngModel] plus (ngModelChange). Splitting it is handy when you want to transform the value, e.g. (ngModelChange)="name = $event.trim()".
[(x)] is [x] plus (xChange)x with an output xChange supports itmodel() declares the input and Change output togetherA resolver loads data before a route activates, so the component renders with its data already available. In modern Angular it's a function of type ResolveFn<T> that can call inject() and returns a value, a Promise or an Observable. You register it under the route's resolve key.
How it behaves:
RedirectCommand to a "not found" page.ActivatedRoute.data, or directly as an input with withComponentInputBinding().The trade-off is perceived performance: nothing on screen changes until the data arrives, so the app can feel frozen on slow connections. Show a global progress bar from router events, or load the data in the component instead (with httpResource() or an Observable) and render a skeleton. Resolvers fit best when the page is meaningless without its data.
ResolveFn with inject(); runs after guardsActivatedRoute.data or component inputs@defer blocks, and how do they work?mid@defer lazy loads a section of a template. The components, directives and pipes used only inside the block are split into a separate chunk and loaded when a trigger fires, which keeps them out of the initial bundle.
Triggers:
on idle (the default): when the browser is idle.on viewport, on interaction, on hover: based on the placeholder or a referenced element.on immediate, on timer(2s), or when condition for a custom expression.prefetch on ... downloads the code earlier than it's rendered.Companion blocks: @placeholder shows before loading starts (with an optional minimum display time), @loading shows while the chunk downloads (after and minimum prevent flicker), and @error shows if loading fails.
Gotchas: dependencies must be standalone, and if they're referenced anywhere else in the same file (another part of the template, or a viewChild query) they're bundled eagerly. During SSR, the server renders the @placeholder. With incremental hydration, hydrate on triggers let the server render the real content and hydrate it later.
@defer (on viewport; prefetch on idle) {
<app-comments [postId]="postId()" />
} @placeholder (minimum 300ms) {
<p>Comments</p>
} @loading (after 100ms; minimum 500ms) {
<app-spinner />
} @error {
<p>Could not load comments.</p>
}idle (default), viewport, interaction, hover, timer, when@placeholder, @loading, @error sub-blocksLikely follow-up: When would you pick @defer over lazy loading a route?
View encapsulation controls whether a component's styles stay inside that component. It's set with the encapsulation option:
ViewEncapsulation.Emulated (the default): Angular adds generated attributes to the component's elements (like _ngcontent-... and _nghost-... on the host) and rewrites its CSS selectors to include them. Styles don't leak out to other components, but global styles still apply inside.ViewEncapsulation.ShadowDom: uses the browser's native Shadow DOM, so isolation works in both directions. Only inherited properties and CSS custom properties cross the boundary.ViewEncapsulation.None: no scoping; the styles become global once the component loads.Related selectors: :host styles the component's own host element, and :host-context(.dark) applies styles based on an ancestor. ::ng-deep pushes a rule into child components; it's deprecated but still supported. For theming children, CSS custom properties are the cleaner option, since they pass through every encapsulation mode.
:host styles the host; ::ng-deep is deprecatedA template reference variable is declared with #name on an element, and gives you a handle you can use elsewhere in the same template. What it refers to depends on where it's placed:
HTMLInputElement.exportAs name: that directive instance, e.g. #f="ngForm" or #m="matMenu".<ng-template>: its TemplateRef.Common uses are reading an input's value without binding it to a field, letting one element control another, and naming elements for viewChild('name').
Scope: a variable is visible throughout its template, but one declared inside an @if or @for block (or an <ng-template>) isn't visible outside that block. For values computed in the template, @let declares a local variable instead.
<input #box placeholder="New todo" (keyup.enter)="add(box.value)" />
<button (click)="add(box.value)">Add</button>
<app-video-player #player src="intro.mp4" />
<button (click)="player.play()">Play</button>
<form #f="ngForm" (ngSubmit)="save(f.value)">
<input name="email" ngModel required />
<button [disabled]="f.invalid">Save</button>
</form>#name in the template#f="ngForm" binds a directive via exportAsng-template it is a TemplateRefFirst measure: the Angular DevTools profiler shows which components are checked and how long each pass takes, Lighthouse covers load metrics, and bundle analysis shows what's in each chunk. Then work on two fronts.
Load time
loadComponent/loadChildren, and heavy template sections with @defer.angular.json, and keep large libraries out of the main chunk.NgOptimizedImage (ngSrc, plus priority on the LCP image) for sizing and lazy loading of images.Runtime
track expression in @for (trackBy for *ngFor), so DOM nodes are reused instead of recreated.computed() instead of method calls in templates, which re-run on every check.cdk-virtual-scroll-viewport) for long lists.switchMap, and clean up subscriptions.NgZone.runOutsideAngular().@defer heavy sectionstrack in @for; no method calls in templatesNgOptimizedImage, SSRLikely follow-up: What does the track expression in @for actually do?
Errors are handled at several levels:
HttpClient errors with an HttpErrorResponse. status holds the HTTP status code, and 0 usually means a network or CORS failure. Use catchError to return a fallback (of([])) or rethrow a friendlier error with throwError(() => ...). Inside a switchMap, catch on the inner Observable so the outer stream survives.retry({ count: 2, delay: 1000 }) resubscribes, which re-sends the request. Use it for transient failures and idempotent requests, not blindly for writes.401 or showing a toast.ErrorHandler, which logs to the console by default. Provide your own implementation to report to a monitoring service. provideBrowserGlobalErrorListeners() also forwards the window's error and unhandledrejection events to it.With signals, toSignal() rethrows the Observable's error when the signal is read, and resource() exposes an error() signal and an 'error' status for the template to render.
catchError with a fallback or a rethrow; catch inside inner streamsretry with count and delay for transient failuresErrorHandler for global reportingThey solve different problems, and they interoperate.
Signals model state: a value that always exists and changes over time. Reads are synchronous, computed() derivations are memoized and glitch-free, there's no subscription to manage, and templates that read them get fine-grained change detection. Use them for component state, derived values, inputs and shared state in services.
Observables model events and async streams: things that happen over time, possibly with no current value. They shine at time and concurrency logic: debouncing, cancellation with switchMap, retries, combining several async sources, WebSockets. HttpClient, the router and reactive forms all expose Observables.
A common split is RxJS at the edges for async orchestration and signals for the state the template reads. Bridge the two with toSignal() and toObservable(), and use resource() or httpResource() for signal-driven data fetching.
Signals don't replace all of RxJS: a signal has no notion of completion or error events, and if you set it twice synchronously, consumers only see the last value.
toSignal() / toObservable()Likely follow-up: How does toSignal() handle errors from the Observable?
Zone.js monkey-patches the browser's async APIs (addEventListener, setTimeout, Promise, XHR and more), so Angular hears about every task that runs. When a task finishes, NgZone triggers ApplicationRef.tick(), a change detection pass from the root. That's why mutating any field in any callback "just works".
The costs: extra bundle size and startup work, a pass after every task even if nothing changed (a mousemove handler, a polling timer), noisier stack traces, and native async/await can't be patched, so the CLI has to downlevel it.
Zoneless change detection drops Zone.js. Angular schedules a pass only when it's notified: a signal read in a template changes, a template or host listener runs, markForCheck() is called (the async pipe does this), ComponentRef.setInput() runs, or a view is attached or removed. Recent Angular versions are zoneless by default; older apps opt in with provideZonelessChangeDetection() and remove zone.js from the polyfills.
Migration checklist: keep async state in signals or the async pipe, make components OnPush-compatible, replace NgZone.onStable with afterNextRender(), and use PendingTasks so SSR waits for async work.
tick()async/await downlevelingmarkForCheck, setInput notifyprovideZonelessChangeDetection(); default in recent versionstoSignal() and toObservable() work, and what should you watch out for?hardBoth come from @angular/core/rxjs-interop and bridge the two reactive models.
toSignal(obs$):
injector;undefined until the first emission, unless you pass initialValue, or requireSync: true for sources that emit synchronously such as a BehaviorSubject;toObservable(sig):
effect, so emissions are asynchronous, and several synchronous set() calls collapse into a single emission of the latest value;The classic pattern is signal, then toObservable, then RxJS operators such as debounceTime and switchMap, then toSignal for the template. For "refetch when a signal changes", rxResource() or httpResource() express it more directly.
export class UserSearch {
private readonly api = inject(UserApi);
readonly query = signal('');
readonly results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q).pipe(catchError(() => of([])))),
),
{ initialValue: [] },
);
}toSignal subscribes immediately and cleans up on destroyundefined until first value unless initialValue/requireSynctoSignal rethrows errors on readtoObservable emits asynchronously via an effectFormControl often typed as string | null?midFormControl, FormGroup and FormArray carry the type of their value, inferred from the initial values, so form.value, valueChanges and setValue() are all type-checked.
The details interviewers probe:
new FormControl('') is FormControl<string | null>, because reset() sets a control to null by default. Pass { nonNullable: true }, or build the form with NonNullableFormBuilder (fb.nonNullable), and reset() returns to the initial value, so the type is just string.form.value is a Partial<...> because disabled controls are left out of it. form.getRawValue() includes every control and has the full type.form.controls.email is fully typed, so a misspelled key is a compile error.FormRecord handles dynamic keys with a single value type, and FormArray<FormControl<string>> a typed list.UntypedFormControl and UntypedFormGroup are escape hatches for migrating older code.export class Signup {
private readonly fb = inject(NonNullableFormBuilder);
readonly form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
age: [18],
});
// form.controls.email: FormControl<string>
// form.value: Partial<{ email: string; age: number }>
// form.getRawValue(): { email: string; age: number }
clear() {
this.form.reset(); // back to '' and 18, never null
}
}reset() sets null by default, hence string | nullnonNullable / NonNullableFormBuilder remove nullvalue is Partial (disabled omitted); getRawValue() is completeFormRecord for dynamic keysControlValueAccessor, and how do you make a custom component work with formControlName and ngModel?hardControlValueAccessor is the bridge between Angular forms and a form control's UI. Built-in accessors connect <input>, <select> and checkboxes. For a custom widget, such as a star rating or a date picker, you implement it yourself:
writeValue(value): the form pushes a value into the component (initial value, setValue, reset).registerOnChange(fn): save fn and call it when the user changes the value, to update the form model.registerOnTouched(fn): save it and call it on blur or interaction, so touched and updateOn: 'blur' work.setDisabledState(isDisabled) (optional): react to control.disable().Register the component as an accessor with an NG_VALUE_ACCESSOR provider, using multi: true and useExisting. forwardRef is needed because the class is referenced inside its own decorator, before it's defined.
After that, the component works with formControlName, [formControl] and [(ngModel)], and validators, touched state and CSS status classes all come for free. To bundle a built-in validation rule with the component, also provide NG_VALIDATORS and implement Validator.
@Component({
selector: 'app-star-rating', templateUrl: './star-rating.html',
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StarRating), multi: true }],
})
export class StarRating implements ControlValueAccessor {
protected readonly value = signal(0);
protected readonly disabled = signal(false);
private onChange = (_: number) => {}; private onTouched = () => {};
writeValue(v: number | null) { this.value.set(v ?? 0); }
registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(d: boolean) { this.disabled.set(d); }
protected select(n: number) { this.value.set(n); this.onChange(n); this.onTouched(); }
}writeValue, registerOnChange, registerOnTouched, setDisabledStateNG_VALUE_ACCESSOR with useExisting and multi: trueforwardRef because the class is not yet definedformControlName and ngModelI pick the lightest tool that fits:
signal, expose read-only state through asReadonly() and computed() selectors, and make methods the only way to change it. Scope it with providedIn: 'root' or component and route providers. Before signals, the same pattern used a private BehaviorSubject.@ngrx/signals): a lighter, signal-based store built from features like withState, withComputed and withMethods.I start with services and signals, and move to NgRx when many features share complex state, changes need to be traceable, or a large team benefits from enforced conventions. Server data (caching, refetching) often fits httpResource() or a query library better than a global store.
@Injectable({ providedIn: 'root' })
export class TodoStore {
private readonly todosState = signal<Todo[]>([]);
readonly todos = this.todosState.asReadonly();
readonly remaining = computed(() => this.todos().filter((t) => !t.done).length);
add(title: string) {
this.todosState.update((list) => [...list, { id: crypto.randomUUID(), title, done: false }]);
}
toggle(id: string) {
this.todosState.update((list) => list.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
}
}computed()SSR renders the app to HTML on the server (Node) for each request, so users see content before the JavaScript loads, which helps first paint, LCP and SEO. Prerendering (SSG) does the same at build time. You add it with ng new --ssr or ng add @angular/ssr, and a server routes config picks a render mode per route.
Hydration (provideClientHydration()) lets the browser reuse the server-rendered DOM instead of re-creating it, avoiding flicker and wasted work. Useful features:
withEventReplay() records clicks made before hydration finishes and replays them.withIncrementalHydration() with @defer (hydrate on viewport) keeps parts of the page dehydrated until needed.HttpClient's transfer cache reuses GET responses fetched on the server, so the client doesn't request them again.Watch out for:
window, localStorage) don't exist on the server. Use afterNextRender() or isPlatformBrowser().ngSkipHydration.@defer (hydrate on ...)afterNextRender() or behind isPlatformBrowserDomSanitizer?midAngular treats every bound value as untrusted by default:
{{ }} and text bindings are escaped, so <script> shows up as text.[innerHTML] is sanitized: scripts, event-handler attributes and other dangerous markup are stripped, with a dev-mode warning.[href] and [src] are sanitized, so a javascript: URL is neutralized.<iframe> src, can't be bound to untrusted values at all; Angular throws.DomSanitizer has bypassSecurityTrustHtml, ...Url, ...ResourceUrl, ...Style and ...Script, which mark a value as trusted and skip sanitization. Use them only for content you fully control or have sanitized yourself, such as an embed URL you built from a video id, never for raw user input.
You lose this protection by going around Angular: writing to nativeElement.innerHTML, building templates from user input, or misusing the bypass methods. Add a CSP and Trusted Types for defense in depth. For CSRF, HttpClient reads an XSRF-TOKEN cookie and sends it as an X-XSRF-TOKEN header on mutating requests to relative URLs.
export class VideoEmbed {
private readonly sanitizer = inject(DomSanitizer);
readonly videoId = input.required<string>();
// We build the URL ourselves from a validated id, so trusting it is safe
readonly src = computed(() =>
this.sanitizer.bypassSecurityTrustResourceUrl(
`https://www.youtube.com/embed/${encodeURIComponent(this.videoId())}`,
),
);
}
// <iframe [src]="src()"></iframe>[innerHTML] is sanitized; resource URLs need explicit trustDomSanitizer.bypassSecurityTrust* only for controlled contentHttpClientTestBed and component harnesses?midTestBed creates a small Angular environment for a test. You configure it like an app, with imports (the standalone component under test) and providers (fakes for its dependencies), then:
TestBed.createComponent(Comp) returns a ComponentFixture, with componentInstance, nativeElement and componentRef.setInput() for inputs.fixture.detectChanges(), or in zoneless tests await fixture.whenStable().TestBed.inject(Service), or even as plain classes.provideHttpClient() and provideHttpClientTesting(), then use HttpTestingController to expectOne() a request, flush() a response and verify() that nothing else was sent.Component harnesses (from @angular/cdk/testing) are page-object APIs for components, e.g. MatButtonHarness. Tests call await button.click() instead of querying DOM internals, so they keep working when a component's markup changes. Angular Material ships harnesses for its components, and you can write your own.
New CLI projects use Vitest as the default runner; older projects typically run Karma with Jasmine. End-to-end tests usually use Playwright or Cypress.
it('shows the user name', async () => {
TestBed.configureTestingModule({
imports: [UserCard],
providers: [{ provide: UserService, useValue: { current: signal({ name: 'Ada' }) } }],
});
const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('compact', true);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Ada');
});TestBed configures imports and fake providerscreateComponent returns a fixture; setInput for inputsdetectChanges() or whenStable() to renderHttpTestingController to expect and flush requestsThe Angular CLI (ng) scaffolds, builds, serves, tests and updates Angular projects, and applies code changes through schematics.
ng new my-app: create a workspace with a configured project.ng serve: start the dev server with rebuilds on save.ng generate (ng g): scaffold a component, service, directive, pipe, guard, interceptor and more, e.g. ng g c user-card.ng build: a production build by default, with AOT compilation, minification, tree-shaking, hashed file names and bundle budget checks.ng test: run unit tests.ng add <package>: install a library and run its setup schematic, e.g. ng add @angular/material or ng add @angular/ssr.ng update: upgrade Angular and run migrations that rewrite your code for breaking changes.Project configuration lives in angular.json: builders, build configurations (development, production), fileReplacements for environments, budgets and assets. New projects use the esbuild-based application builder with a Vite-powered dev server.
ng new, ng serve, ng generate, ng build, ng testng build is a production build: AOT, minified, tree-shakenng add installs and configures librariesng update upgrades and runs migrationsangular.json holds builders, configurations and budgetslinkedSignal, and how is it different from computed?hardlinkedSignal creates a writable signal whose value is derived from other signals but can also be overridden locally.
computed(), it recomputes whenever the signals it depends on change.computed(), you can call set() and update() on it. A local value holds until the source changes again, and then the computation resets it.Typical cases are a selection that should reset when its list of options changes, and an editable draft of an input value that resets when a new record arrives.
There are two forms:
linkedSignal(() => this.options()[0]).{ source, computation }: the computation receives the new source value and previous ({ source, value }), so it can keep the user's choice when it's still valid.It replaces the anti-pattern of an effect() that copies one signal into another writable signal. If the value never needs a manual override, use computed().
export class ShippingPicker {
readonly options = input.required<ShippingOption[]>();
// Resets to the first option whenever options change; the user can still pick another
readonly selected = linkedSignal(() => this.options()[0]);
// Keeps the user's choice if it still exists in the new list
readonly kept = linkedSignal<ShippingOption[], ShippingOption>({
source: this.options,
computation: (opts, previous) => opts.find((o) => o.id === previous?.value.id) ?? opts[0],
});
}set() holds until the source changescomputed() is read-only; linkedSignal is overridableprevious lets you preserve a still-valid valueresource() and httpResource(), and what problem do they solve?hardThey bring async data loading into the signal model, so you don't have to hand-wire "when this signal changes, fetch, track loading, handle errors".
resource() takes a params function that reads signals and a loader that returns a Promise. Whenever the params change, it runs the loader again and aborts the previous run through the abortSignal it passes in. If params returns undefined, the loader doesn't run and the resource stays idle. It exposes signals and methods:
value(), hasValue(), error(), isLoading(),status(): 'idle', 'loading', 'reloading', 'resolved', 'error' or 'local' (after a local set()),reload(), set() and update().Variants: rxResource() from @angular/core/rxjs-interop takes an Observable-returning stream function, and httpResource() from @angular/common/http takes a reactive URL or request and goes through HttpClient, so interceptors and testing tools apply.
Pitfalls: value() throws while the resource is in the error state, so check hasValue() or error() in the template. Resources are for reading data; send writes (POST, PUT, DELETE) with HttpClient directly.
export class UserDetail {
readonly id = input.required<string>();
readonly user = httpResource<User>(() => `/api/users/${this.id()}`);
}
// Template:
// @if (user.isLoading()) { <app-spinner /> }
// @else if (user.error()) { <p>Could not load user.</p> }
// @else if (user.hasValue()) { <h2>{{ user.value().name }}</h2> }rxResource for Observables, httpResource via HttpClientvalue() throws in error state: check hasValue()ExpressionChangedAfterItHasBeenCheckedError (NG0100), and how do you fix it properly?hardIt's a development-mode check. After each change detection pass, Angular runs a second verification pass over the same bindings. If one now produces a different value than the one it just rendered, it throws NG0100, because the screen would disagree with the state. Production builds skip the check, so there the same bug silently shows stale values.
Common causes:
ngAfterViewInit or ngAfterViewChecked, after the view was already checked.Date.now() or Math.random().Proper fixes:
ngOnInit.computed() instead of assigning them in lifecycle hooks, or make the value a signal.Wrapping the assignment in setTimeout or calling detectChanges() hides the symptom without fixing the flow.
@Component({
selector: 'app-banner',
changeDetection: ChangeDetectionStrategy.Eager, // named Default in older versions
template: `<p>{{ message }}</p>`,
})
export class Banner implements AfterViewInit {
message = 'Loading';
ngAfterViewInit() {
this.message = 'Loaded'; // NG0100: the view was already checked with 'Loading'
}
}ngAfterViewInitcomputed(), signalssetTimeout or detectChanges() only hide itCommon cases are dialogs, dashboards built from configuration and plugin widgets. There are three main tools:
NgComponentOutlet, e.g. <ng-container *ngComponentOutlet="widget.component; inputs: widget.inputs" />. It's declarative, and the simplest option when a config object decides the component.ViewContainerRef.createComponent(Comp) inserts the component at a location, taken from inject(ViewContainerRef) or a query with { read: ViewContainerRef }. It returns a ComponentRef. Set inputs with ref.setInput('title', value), which also marks it for check, subscribe to outputs through ref.instance, and call ref.destroy() when you're done. Newer versions also accept bindings such as inputBinding() and outputBinding() at creation time.document.body: the standalone createComponent(Comp, { environmentInjector, hostElement }), then appRef.attachView(ref.hostView) so change detection reaches it.Combine these with a dynamic import() so the component's code loads only when needed. Older code went through ComponentFactoryResolver; since Ivy you pass the component class directly, and recent versions have removed that API.
@Component({
selector: 'app-dashboard',
template: `<ng-container #slot />`,
})
export class Dashboard {
private readonly slot = viewChild.required('slot', { read: ViewContainerRef });
async addChart(title: string) {
const { SalesChart } = await import('./sales-chart');
const ref = this.slot().createComponent(SalesChart);
ref.setInput('title', title);
ref.instance.closed.subscribe(() => ref.destroy());
}
}NgComponentOutlet for declarative dynamic renderingViewContainerRef.createComponent() returns a ComponentRefsetInput() for inputs; destroy() to clean upimport() for lazy loadingComponentFactoryResolver needed any moreAngular's built-in i18n uses @angular/localize (added with ng add @angular/localize) and works at build time:
i18n attribute, optionally with a meaning, a description and a custom id: i18n="site header|Welcome message@@homeWelcome". Attributes use i18n-<attr>, e.g. i18n-title.$localize tagged template.{count, plural, =0 {...} other {...}} and select.ng extract-i18n to generate a source translation file (XLIFF by default); translators return one file per locale.angular.json and build with localize. The CLI produces a separate, fully translated build per locale, so there's no runtime lookup cost.Locale-aware pipes (date, currency, number) follow LOCALE_ID.
The trade-off: switching language means loading a different build, usually served under a URL prefix like /fr/. For switching at runtime without a reload, teams use libraries such as Transloco or ngx-translate.
<h1 i18n="site header|Welcome message@@homeWelcome">Welcome to Stacktrick</h1>
<img [src]="logoUrl" i18n-alt alt="Stacktrick logo" />
<span i18n>
{count, plural, =0 {No new messages} =1 {One new message} other {{{count}} new messages}}
</span>i18n, attributes with i18n-attr$localize for strings in TypeScriptng extract-i18n produces translation filesNo questions match that filter.