Angular · cheat sheet

Angular

Modern Angular for interviews: standalone components, signals, input/output/model, control flow, change detection, DI, routing, forms, HttpClient and testing.

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.
  • Bootstrapping: bootstrapApplication(App, appConfig) with app-wide providers (provideRouter(routes), provideHttpClient(...)). Classic: platformBrowser().bootstrapModule(AppModule) (older code uses platformBrowserDynamic()).
  • Templates are compiled ahead of time (AOT, the default) by the Ivy compiler, so the browser never parses them.
  • Current defaults: zoneless change detection (v21+) and OnPush components (v22). Older apps use Zone.js and the Default (now Eager) strategy.
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withComponentInputBinding()),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};
bootstrapApplication(App, appConfig);
TypeScript

Components

@Component({
  selector: 'app-user-card',
  imports: [DatePipe, RouterLink],   // what the template uses
  templateUrl: './user-card.html',
})
export class UserCard {
  user = input.required<User>();                            // InputSignal<User>
  active = input(false, { transform: booleanAttribute });  // <app-user-card active>
  selected = output<User>();                                // (selected)="..."
  expanded = model(false);                                  // [(expanded)]="open"
  select() { this.selected.emit(this.user()); }
}
TypeScript
Metadata Purpose
selector element (app-x), attribute ([appX]) or class selector
template / templateUrl inline or external template
styles / styleUrl(s) scoped CSS (encapsulation: Emulated default, ShadowDom, None)
imports components, directives and pipes the template uses
providers / viewProviders services for this component and its children (viewProviders: not visible to projected content)
changeDetection OnPush (default in v22) or Eager
host host bindings and listeners; preferred over @HostBinding and @HostListener
hostDirectives compose directives onto the host element
  • Signal inputs are read-only signals: read this.user(), never assign. input.required() has no undefined in its type; missing it is a build error.
  • model() is a writable input: this.expanded.set(true) updates the parent through the generated expandedChange output.
  • Classic: @Input() user!: User, @Input({ required: true }), @Output() selected = new EventEmitter<User>().
  • Queries: viewChild('ref'), viewChild.required(Chart), viewChildren(Item), contentChild(), contentChildren() return signals. Classic: @ViewChild('ref') el!: ElementRef.

Templates & control flow

Binding Syntax Direction
Interpolation {{ user().name }} class → text (HTML-escaped)
Property [disabled]="busy()", [user]="u" class → DOM property or child input
Attribute [attr.aria-label]="label" class → HTML attribute
Class / style [class.active]="on", [style.width.px]="w" class → element
Event (click)="save($event)", (keyup.enter)="go()" DOM or child output → class
Two-way [(ngModel)]="name", [(expanded)]="open" “banana in a box”: [x] plus (xChange)
Template variable #box then box.value a reference inside the template
@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
  • @for requires track: 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) *ngIf, *ngFor, a custom *appHasRole
@Directive({
  selector: '[appHighlight]',
  host: {
    '(mouseenter)': 'hovered.set(true)',
    '(mouseleave)': 'hovered.set(false)',
    '[style.backgroundColor]': 'hovered() ? color() : null',
  },
})
export class Highlight {
  color = input('yellow', { alias: 'appHighlight' });  // <p appHighlight="pink">
  hovered = signal(false);
}
TypeScript
@Pipe({ name: 'initials' })  // pure by default
export 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) { ... }.
  • Built-ins: date, currency, number, percent, uppercase, lowercase, titlecase, json, keyvalue, slice, async, i18nPlural.

Component communication

Need Modern Classic
Parent to child input() @Input()
Child to parent output() + .emit() @Output() + EventEmitter
Two-way model() + [(value)] @Input() value + @Output() valueChange
Parent calls child viewChild(Child) or #child in the template @ViewChild(Child)
Projected content <ng-content select="[title]" />, contentChild() @ContentChild()
Siblings / unrelated a shared service holding signals a service with a BehaviorSubject
Route data withComponentInputBinding() + input() ActivatedRoute.paramMap
Whole subtree provide a service on an ancestor component same

Lifecycle hooks

Order Hook Runs Typical use
1 constructor on creation inject(); inputs are not set yet
2 ngOnChanges(changes) before ngOnInit, then whenever a bound input changes react to input changes (SimpleChanges)
3 ngOnInit once initialization that needs inputs
4 ngDoCheck every change detection pass custom change checks (keep it cheap)
5 ngAfterContentInit / ngAfterContentChecked after projected content is initialized / checked content queries ready
6 ngAfterViewInit / ngAfterViewChecked after the view and child views are initialized / checked view queries and DOM ready
7 afterNextRender / afterEveryRender after the DOM is rendered (browser only, never on the server) measure the DOM, third-party widgets
last ngOnDestroy or inject(DestroyRef).onDestroy(fn) before destruction cleanup
  • On later passes the order is ngOnChanges (if inputs changed), ngDoCheck, ngAfterContentChecked, ngAfterViewChecked, afterEveryRender.
  • With signal inputs, computed() replaces most ngOnChanges logic.

Signals vs RxJS

const count = signal(0);                         // WritableSignal<number>
const double = computed(() => count() * 2);      // read-only, lazy, memoized
count.set(5);
count.update((c) => c + 1);
effect(() => console.log('count is', count()));  // needs an injection context
const options = signal(['S', 'M', 'L']);
const size = linkedSignal(() => options()[0]);   // writable; resets when options changes
const userId = signal('1');
const user = resource({ params: () => ({ id: userId() }), loader: ({ params }) => fetchUser(params.id) });
TypeScript
  • 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().
Signals RxJS Observables
Models a value that changes; always has a current value events or values over time
Read synchronously: count() subscribe, async pipe
Subscriptions automatic dependency tracking, no cleanup manual: unsubscribe or an operator
Derived values computed(), glitch-free map, combineLatest
Time and async operators none (use resource) debounceTime, switchMap, retry…
Best for component and UI state, templates HTTP, WebSockets, user-event streams, orchestration
Interop toSignal(obs$) toObservable(signal)

Change detection

  • 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-shakable
export class UserApi {
  private http = inject(HttpClient);
  private base = inject(API_URL);
  get(id: string) { return this.http.get<User>(`${this.base}/users/${id}`); }
}
TypeScript
const providers: Provider[] = [
  { provide: API_URL, useValue: '/api' },
  { provide: Logger, useClass: ConsoleLogger },          // swap the implementation
  { provide: Clock, useFactory: () => new Clock(inject(API_URL)) },
  { provide: AbstractStore, useExisting: UserStore },    // alias: same instance
];
TypeScript
  • 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.

Routing

export const authGuard: CanMatchFn = () =>
  inject(Auth).isLoggedIn() || inject(Router).createUrlTree(['/login']);
export const userResolver: ResolveFn<User> = (route) => inject(UserApi).get(route.paramMap.get('id')!);

export const routes: Routes = [
  { path: '', component: Home },
  { path: 'users/:id', component: UserDetail, resolve: { user: userResolver } },
  { path: 'settings', loadComponent: () => import('./settings').then((m) => m.Settings) },
  { path: 'admin', canMatch: [authGuard], loadChildren: () => import('./admin.routes').then((m) => m.ADMIN_ROUTES) },
  { path: 'old', redirectTo: 'settings', pathMatch: 'full' },
  { path: '**', component: NotFound },
];
TypeScript
  • 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.
  • Template and navigation: <router-outlet />, routerLink="/users/1", routerLinkActive="active", router.navigate(['/users', id], { queryParams: { tab: 'posts' } }).
  • Preload lazy chunks with withPreloading(PreloadAllModules); give a route its own services with providers: [...].

Forms

Template-driven Reactive
Import FormsModule ReactiveFormsModule
Model created implicitly by ngModel explicit FormGroup / FormControl in the class
Binding [(ngModel)]="user.name" [formGroup]="form" + formControlName="email"
Validation attributes: required, minlength, email functions: Validators.required
Data flow asynchronous synchronous
Testing needs the DOM test the model directly
Best for small, simple forms complex, dynamic or typed forms
export const noSpaces: ValidatorFn = (c) => (/\s/.test(c.value) ? { noSpaces: true } : null);

export class SignupForm {
  private fb = inject(NonNullableFormBuilder);
  form = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    username: ['', [Validators.required, noSpaces]],
  });
  submit() {
    if (this.form.valid) console.log(this.form.getRawValue()); // { email: string; username: string }
  }
}
TypeScript
  • 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.

HTTP & RxJS in Angular

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const token = inject(Auth).token();
  const router = inject(Router);  // inject now: catchError runs later, outside the context
  const authed = token ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }) : req;
  return next(authed).pipe(
    catchError((err: HttpErrorResponse) => {
      if (err.status === 401) router.navigate(['/login']);
      return throwError(() => err);
    }),
  );
};
TypeScript
  • 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.
esc