Angular NotesRohit’s interview study guide
Chapter 17

Review Questions

Test yourself — Angular interview questions with worked answers.

5 topics

#Components and templates questions

These questions check whether you can say what a template actually does: which binding writes a string and which writes a value, what control flow compiles to, how @for decides which row is which, when a pipe re-runs, and how data and events cross component boundaries. Answer each one out loud before you open it. The first paragraph of each answer is what you'd say in the interview; the walkthrough is the reasoning you should be able to give when they ask "why?". Every output quoted here comes from the Angular 22 specs behind chapters 3 to 6.

Interpolation vs property binding: what does this render?
HTML
<button disabled="{{ locked }}">A</button>
<button [disabled]="locked">B</button>

locked is false. And what happens if you pass an object to a child input with user="{{ me }}"?

Show answer

Button A is disabled and button B is enabled. Interpolation always produces a string, so A gets button.disabled = 'false', and a non-empty string is truthy. user="{{ me }}" doesn't compile: strict templates report TS2322: Type 'string' is not assignable to type 'User'.

What’s happening
  1. disabled="{{ locked }}" compiles to ɵɵproperty("disabled", ɵɵinterpolate(ctx.locked)). The interpolation turns false into the string 'false' before it's assigned.
  2. The browser coerces the string for the boolean disabled property: any non-empty string is true. The test showed disabled: true while locked was false.
  3. [disabled]="locked" compiles to ɵɵproperty("disabled", ctx.locked): the real boolean false is assigned and B is enabled.
  4. The compiler didn't catch button A, because Angular doesn't type-check bindings to native DOM properties. It does type-check component inputs, which is why user="{{ me }}" fails: interpolation can only ever produce a string.
  5. The rule to say out loud: interpolation for text, property binding for everything else — booleans, numbers, objects and every component input.

Learn more: Interpolation vs property binding and Property binding.

Why does <td [colspan]="span()"> fail to compile, and how do you make an ARIA attribute conditional?
Show answer

Because colspan is only the attribute name. Property binding needs a DOM property, and the property is spelled colSpan, so the build fails with NG8002: Can't bind to 'colspan' since it isn't a known property of 'td'. Use [colSpan] or [attr.colspan]. To make an attribute conditional, bind null: [attr.aria-label]="label()" removes the attribute when label() is null.

What’s happening
  1. [x] means "set the DOM property x". For a native element the compiler checks that the property exists, and HTMLTableCellElement has colSpan (camel case), not colspan.
  2. [colSpan]="span()" compiles to ɵɵdomProperty("colSpan", …) and the browser reflects it to the colspan attribute.
  3. [attr.colspan]="span()" compiles to ɵɵattribute("colspan", ctx.span()) — setAttribute('colspan', '2'). Attributes are always strings: [attr.data-id]="7" writes "7".
  4. For ARIA, data-*, role and SVG attributes there's no property at all, so [attr.x] is the tool. Binding null or undefined removes the attribute; the test's button became <button data-id="7">X</button> after label.set(null).
  5. Angular 22 also maps [aria-label] (no attr. prefix) to an attribute for you, but [attr.aria-label] is the explicit form that works everywhere.

Learn more: Attribute binding.

What does <p>{{ count }}</p> show when count = signal(5)?
Show answer

Not 5. In a development build it shows [Signal (count): 5] — the signal function stringified — and the build warns NG8109: count is a function and should be invoked: count(). Worse, the view never subscribed to the signal, so later count.set(6) doesn't re-render it. Always call it: {{ count() }}.

What’s happening
  1. A signal is a getter function. {{ count }} interpolates the function object itself, not its value.
  2. The compiler notices and warns twice: NG8109 and NG8117: Function in text interpolation should be invoked: count(). The build still succeeds, which is how this slips through.
  3. Calling count() does two jobs: it returns the value and registers the view as a consumer of the signal. That registration is how a zoneless, OnPush-by-default app knows to re-render exactly this view when the signal changes.
  4. So the uncalled version is wrong twice: wrong text now, and no reactivity later.
  5. Calling a signal in a template isn't the "method call in a template" anti-pattern: a signal read just returns a stored value.

Learn more: Binding to component properties and signals and Signals vs plain properties.

@if vs *ngIf: what's the difference, and what happens if you forget to import NgIf?
Show answer

Both create and destroy DOM rather than hiding it. *ngIf is a structural directive: the * desugars to an <ng-template> and NgIf stamps it out, so it must be imported, needs ng-template refs for else, and creates a directive instance per use. @if is built-in syntax compiled to one ɵɵconditional instruction: no import, real @else if/@else, and it's what Angular 22 recommends (NgIf is deprecated since v20). Forget NgIf in a standalone component and the build only warns (NG8103); at runtime the paragraph never renders.

What’s happening
  1. <p *ngIf="user; else signIn"> is rewritten to <ng-template [ngIf]="user" [ngIfElse]="signIn"><p>…</p></ng-template>. The conditional logic lives in the NgIf class; the compiler only sets its inputs.
  2. @if (loading()) {…} @else if (user(); as u) {…} @else {…} compiles to a single ɵɵconditional(ctx.loading() ? 0 : (tmp_0_0 = ctx.user()) ? 1 : 2, tmp_0_0). Angular computes a branch index and only touches the DOM when the index changes.
  3. Without NgIf (or CommonModule) in imports, <p *ngIf="true"> builds with NG8103: The *ngIf directive was used in the template, but neither the NgIf directive nor the CommonModule was imported. At runtime the console logs NG0303: Can't bind to 'ngIf'… and the DOM is just an anchor comment: the * created a template and nothing rendered it.
  4. @if needs no wrapper element and no import, and the ng generate @angular/core:control-flow schematic migrates *ngIf for you.

Learn more: @if vs *ngIf and *ngIf and ng-template.

Why does @if (user()) { {{ user().name }} } fail strict template checking?

user = signal<User | null>(null).

Show answer

Because checking user() doesn't narrow a second call to user(). The type checker reports TS2531: Object is possibly 'null'. Read it once: @if (user(); as u) { {{ u.name }} }, or use @let.

What’s happening
  1. The template type checker turns the block into TypeScript roughly like if (ctx.user()) { ctx.user().name }.
  2. TypeScript can't assume a function returns the same value twice, so the second user() is still User | null.
  3. @if (user(); as u) stores the condition's value in a template variable u. Inside the block u is User, so u.name compiles. The compiled code also reads user() only once (tmp_0_0).
  4. With a plain field, @if (user) { {{ user.name }} } does narrow, like an if in TypeScript. The trap is specific to signals and other function calls.
  5. The same idea explains why @switch with @default never; needs @let s = status(); first.

Learn more: @if and @else and @switch.

Predict the bug: track $index and removing the first row
TypeScript
@Component({
  selector: 'app-todo-row',
  template: `
    <span>{{ title() }}</span>
    <button (click)="editing.set(true)">edit</button>
    @if (editing()) { <em>editing</em> }
    <input placeholder="note" />
  `,
})
export class TodoRow {
  title = input.required<string>();
  editing = signal(false);
}

// parent template:
// @for (todo of todos(); track $index) { <app-todo-row [title]="todo.title" /> }
// todos: Buy milk, Walk dog, Write notes

The user clicks "edit" on Buy milk and types for Rohit in its note. Then the parent runs todos.update(list => list.slice(1)). What's on screen?

Show answer

Walk dog [editing] note="for Rohit", then Write notes. Walk dog now claims to be in edit mode and shows Buy milk's note, because Angular kept rows 0 and 1, re-bound their titles, and destroyed the last row. With track todo.id the Buy milk row is the one destroyed and no state moves.

What’s happening
  1. editing lives in each TodoRow instance and the note lives in each <input> DOM node. Neither comes from the item, so they stay wherever the row stays.
  2. Before the delete the keys are [0, 1, 2]; after it [0, 1]. With track $index the key is the position, so keys 0 and 1 "still exist".
  3. Angular keeps rows 0 and 1 and just sets their title inputs to 'Walk dog' and 'Write notes'. Key 2 is gone, so it destroys the third row. The test confirmed: DOM rows [true, true, false] survived.
  4. Row 0 still has editing = true and the typed note, but now displays Walk dog. That's the bug.
  5. With track todo.id, keys go [1, 2, 3] → [2, 3]; key 1 is gone, so Buy milk's row — instance, signal, input and all — is destroyed, and the other two rows aren't touched. It's also less work: one removal instead of re-binding every row.

Learn more: Why not track $index and @for and track.

Pure pipe: what do the two lines show after "other", then "push"?
TypeScript
@Pipe({ name: 'pending' })                    // pure (the default)
export class PendingPipe { transform(todos: Todo[]) { calls.pure++; return todos.filter((t) => !t.done); } }

@Pipe({ name: 'pendingImpure', pure: false })
export class PendingImpurePipe { transform(todos: Todo[]) { calls.impure++; return todos.filter((t) => !t.done); } }

// template:
// <p>{{ (todos | pending).length }} pending (pure)</p>
// <p>{{ (todos | pendingImpure).length }} pending (impure)</p>
// <button (click)="clicks.set(clicks() + 1)">other</button>
// <button (click)="addByPush()">push</button>   addByPush() { this.todos.push({ title: 'Write tests', done: false }); }
// todos starts with one undone and one done item
Show answer

After "push" the page shows 1 pending (pure) next to 2 pending (impure), and the counts are { pure: 1, impure: 3 }. The pure pipe is stale: Angular compared todos === lastTodos, got true, and reused its cached result. The fix is an immutable update (todos = [...todos, item]), not pure: false.

What’s happening
  1. First render: both pipes run once and show 1. The pure pipe stores the input it saw — the array reference. Counts { pure: 1, impure: 1 }.
  2. "other": the click marks the view for checking, so the template runs again. The pure pipe sees the same reference and doesn't call transform; the impure one runs anyway. Counts { pure: 1, impure: 2 }.
  3. "push": the item goes into the same array. On the next check the pure pipe's reference check still says "unchanged", so it shows the cached 1. The impure pipe recomputes and shows 2. Counts { pure: 1, impure: 3 }.
  4. Replacing the array ([...this.todos, item]) made the pure pipe run again in the chapter's test and show 3 — the pushed item was in there all along.
  5. The impure pipe was "right" every time, but it ran on every check, including the one where nothing changed. Impure is for pipes whose output depends on something other than the input reference (async, a "time ago" pipe).

Learn more: Pure and impure pipes.

Method, pipe or computed: how many times does each run after three unrelated clicks?
HTML
<p>{{ totalMethod() }}</p>
<p>{{ lines | cartTotal }}</p>
<p>{{ total() }}</p>                 <!-- total = computed(...) over linesSig -->
<button (click)="likes.set(likes() + 1)">like</button>
Show answer

{ method: 4, pipe: 1, computed: 1 }. The method runs on every check of the view; the pure pipe only when its input reference changes; the computed only when a signal it read changes. All three show the same total — the method just recomputes it for nothing.

What’s happening
  1. First render: all three bindings need a value, so each runs once: { method: 1, pipe: 1, computed: 1 }.
  2. Each "like" click changes likes, which has nothing to do with the cart, but the click and the signal mark this view for checking. Angular re-evaluates every binding three times.
  3. {{ totalMethod() }} is a plain function call in the compiled update block. Angular can't know its result hasn't changed without calling it, so it runs three more times: 1 → 4.
  4. {{ lines | cartTotal }} compares lines with the stored reference — same array — and reuses the result. Still 1.
  5. {{ total() }} returns the computed's cached value because linesSig didn't change. Still 1.
  6. Today's answer: computed() for component-specific derived state, pure pipes for reusable formatting, methods only for trivial getters. In a zone.js app with the old Default (now Eager) strategy, the method count would multiply across every event in the app.

Learn more: Pipes vs method calls and Avoid heavy work in templates.

The async pipe on a method: what goes wrong?
TypeScript
@Component({
  imports: [AsyncPipe],
  template: `
    <p>{{ (load() | async)?.name }}</p>
    <button (click)="likes.set(likes() + 1)">like</button>
  `,
})
export class Fresh {
  likes = signal(0);
  load() {
    return this.store.cold$.pipe(map((u) => u)); // a new Observable on every call
  }
}
Show answer

It resubscribes on every check. The async pipe sees a different observable reference each time, unsubscribes from the old one and subscribes to the new one: the test counted 1 subscription after the first render and 4 after three unrelated clicks. With an HTTP call that's one request per check, and it can loop. Create the observable once, as a field, or use toSignal/httpResource.

What’s happening
  1. The async pipe is impure, so transform runs on every check. Each check calls load(), and pipe(map(...)) returns a brand-new Observable object.
  2. Inside the pipe, obj !== this._obj means "a different observable": it calls _dispose() (unsubscribe, reset to null) and subscribes to the new one.
  3. This example "works" only because the source emits synchronously. With http.get, each new subscription starts at null; when the response arrives the pipe calls markForCheck(), which triggers a check, which calls load() again — another request, every time.
  4. Related trap: every | async usage is its own subscription, so three (user$ | async) on one cold http.get field means three requests. Subscribe once with @if (user$ | async; as user) or @let.

Learn more: The async pipe and Multiple subscriptions mean multiple executions.

input() vs model(): predict the parent and child values

Rating has value = model(0), and each star button calls value.set(star). The parent has score = signal(3) and fixed = signal(2):

HTML
<app-rating id="two-way" [(value)]="score" />
<app-rating id="one-way" [value]="fixed()" />

The user clicks star 5 on the first and star 4 on the second. Then the parent runs fixed.set(1). What are score(), fixed() and the second rating's stars at each step? And what if you write [(value)]="score()"?

Show answer

After the clicks: score() is 5, but fixed() is still 2 while the second rating shows 4 stars — the two have silently diverged. After fixed.set(1) the second rating shows 1: the parent's next value overwrites the child's local write. [(value)]="score()" doesn't compile: NG5002: Unsupported expression in a two-way binding.

What’s happening
  1. model() is a writable signal that is also an input named value and an output named valueChange. value.set(5) inside the child changes the local value and emits valueChange(5).
  2. [(value)]="score" is [value]="score()" plus (valueChange)="score.set($event)", so the parent's score becomes 5.
  3. [value]="fixed()" is one-way. The child's model accepts the write (4), emits valueChange(4), but nobody listens, so fixed stays 2.
  4. fixed.set(1) changes the input binding, so Angular pushes 1 into the model and overwrites the child's 4. The parent always wins on its next change.
  5. Two-way binding needs something assignable: the writable signal itself (score), not its value (score()).
  6. With input() instead of model(), the child couldn't write at all — value.set doesn't exist (TS2339). Rule: input() for data the child displays, model() for controls whose job is to change the parent's value.

Learn more: input() vs model() and Two-way binding with model().

Do component outputs bubble? Predict what the board hears
TypeScript
// DeleteButton: deleted = output<void>(); onClick() emits deleted AND dispatches
//               new CustomEvent('removed', { bubbles: true }) from its host element.
// Card template:  <app-delete-button (deleted)="heard.push('card heard deleted')" />
// Board template: <app-card (deleted)="heard.push('board heard deleted')"
//                           (removed)="heard.push('board heard DOM removed')" />

The user clicks the delete button. What's in heard? (Card declares no deleted output.)

Show answer

['card heard deleted', 'board heard DOM removed']. Outputs don't bubble: emit() only calls the handlers bound on that component's host element in its direct parent's template. The board's (deleted) compiled — Angular treated it as a native DOM listener on <app-card> — and simply never fired. The real DOM CustomEvent did bubble up.

What’s happening
  1. this.deleted.emit() calls the listeners subscribed to this DeleteButton instance's output. The only one is in Card's template, so card heard deleted.
  2. emit is synchronous: the parent's handler runs inside emit, as a direct function call. No queue, no propagation.
  3. Board writes (deleted) on <app-card>, but Card has no deleted output. The compiler doesn't complain; any unknown (name) becomes addEventListener('deleted', …) on the element. Angular outputs don't dispatch DOM events, so it never fires.
  4. The CustomEvent('removed', { bubbles: true }) is a real DOM event. It travels up through <app-card>, where the board's (removed) listener hears it.
  5. To reach a grandparent: re-emit through the middle component ((deleted)="deleted.emit()" with its own output), or move the state into a signal-based service when several levels need it.

Learn more: Component events don't bubble and Child to parent with output().

#Signals and change detection questions

Signals and change detection are the same story in Angular 22: a signal read registers a dependency, a signal write tells Angular exactly which view is stale, and zoneless OnPush-by-default change detection only refreshes what was announced. These questions check the rules that come out of that — memoisation, laziness, effects that run late, linked state, the mutation problem, and what does and doesn't trigger a render. The outputs come from the specs behind chapters 8 and 14, plus one extra spec written for this page.

Why is this effect a bad idea, and what does fullViaEffect() return right after first.set('Grace')?
TypeScript
const first = signal('Ada');
const last = signal('Lovelace');
const fullViaEffect = signal('');
effect(() => fullViaEffect.set(`${first()} ${last()}`));
const fullViaComputed = computed(() => `${first()} ${last()}`);

TestBed.tick();
first.set('Grace');
Show answer

fullViaComputed() is 'Grace Lovelace' immediately, but fullViaEffect() still says 'Ada Lovelace' until the effects flush. Effects are scheduled, not synchronous, so state derived through an effect is stale in between, costs an extra writable signal and an extra render. Use computed() for derived state (or linkedSignal if it must also be writable); keep effect() for side effects outside the signal world.

What’s happening
  1. The effect reads first and last and copies the result into a third signal. The computed describes the same value directly.
  2. first.set('Grace') marks both the computed and the effect dirty.
  3. Reading a dirty computed recomputes it synchronously, so fullViaComputed() is right the moment you ask.
  4. The effect only runs at the next flush (TestBed.tick() in the test, change detection in an app). Until then anything reading fullViaEffect() — including a template — sees 'Ada Lovelace'.
  5. Good uses of effect(): localStorage, analytics, document.title, a chart library. Bad uses: setting one signal from another, fetching data (use resource/httpResource), anything that must be correct synchronously.

Learn more: effect() and when not to use it and computed().

Predict the effect log
TypeScript
const a = signal(1);
const b = computed(() => a() * 10);
const log: string[] = [];
effect(() => log.push(`b=${b()}`));

TestBed.tick();
a.set(2);
a.set(3);
TestBed.tick();
a.set(3);
TestBed.tick();
Show answer

['b=10', 'b=30']. The effect ran twice: once initially, and once after the two writes — coalesced into one run that saw the latest value. b=20 was never computed or logged, and the last a.set(3) was dropped by the equality check, so nothing re-ran.

What’s happening
  1. Creating the effect only schedules it. The first TestBed.tick() runs it: it reads b(), which computes 10, and logs b=10. Its dependency is b (and through it, a).
  2. a.set(2) marks b dirty and the effect dirty. a.set(3) before any flush just keeps them dirty — there's no run per write.
  3. The second tick runs the effect once. Reading b() recomputes it with the current a (3) → b=30. The intermediate 2 never reaches anyone.
  4. a.set(3) when a is already 3 is ignored by Object.is, so nothing is marked dirty and the third tick does nothing.
  5. This is the "signals are state, not events" point: effects react to the value when they run, not to every write. If you need every event, keep it in an Observable or a Subject. (Verified in this page's own spec.)

Learn more: effect() and when not to use it and Signals and RxJS together.

Mutation inside a signal: what does total() return?
TypeScript
const items = signal(['a', 'b']);
const total = computed(() => items().length);

total();                                  // 1
items().push('c');
total();                                  // 2
items.set(items());
total();                                  // 3
items.update((arr) => [...arr, 'd']);
total();                                  // 4
Show answer

2, 2, 2, then 4. A signal only notices a new reference. push changes the array's contents without calling set, and items.set(items()) sets the same array, which Object.is treats as "equal", so the write is dropped. Only the spread copy notifies — and it counts 'c' too, because the mutation did happen, it just went unnoticed.

What’s happening
  1. The first read runs the formula once (runs = 1) and caches 2.
  2. items().push('c') gets the array out and mutates it. No signal method ran, so total is never told it's stale; it returns the cached 2 without re-running.
  3. items.set(items()) looks like a fix but passes the same object. Object.is(old, new) is true, so the signal ignores the write. Still 2, still one run.
  4. update((arr) => [...arr, 'd']) builds a new array, the equality check fails, the signal notifies, and total() re-runs (runs = 2) and returns 4.
  5. In a template this is the "I pushed an item and the list didn't update" bug. Treat values inside signals as immutable: spread, map, filter — never push, splice or obj.x = ….

Learn more: Creating and updating signals.

linkedSignal: predict the page number
TypeScript
const filter = signal('all');
const page = linkedSignal(() => {
  filter();
  return 1;
});

page();               // ?
page.set(3);
page();               // ?
filter.set('all');    // same value
page();               // ?
filter.set('done');
page();               // ?
Show answer

1, 3, 3, 1. A linkedSignal is a writable signal that is reset by its computation whenever the computation's dependencies change. The user's set(3) wins until filter really changes; setting filter to the value it already has isn't a change.

What’s happening
  1. The computation reads filter (tracked) and returns 1, so page() starts at 1.
  2. Unlike a computed, it returns a WritableSignal: page.set(3) works and page() is 3.
  3. filter.set('all') is dropped by the equality check — 'all' is already the value — so the computation isn't re-run and the override survives: 3.
  4. filter.set('done') is a real change. The computation runs again and its result 1 replaces the user's 3, even though the computation returned 1 last time too. (Verified in this page's spec; the long form linkedSignal({ source: filter, computation: () => 1 }) behaved the same.)
  5. Compare the alternatives: signal(1) would never reset, computed(() => …) couldn't be set, and an effect that writes page is the anti-pattern linkedSignal replaces. Use the long form with previous when you want to keep the user's choice if it's still valid.

Learn more: linkedSignal.

Which signals does this effect depend on?
TypeScript
effect(() => {
  const p = page();               // tracked
  const u = untracked(user);      // not tracked
  log.push(`${u} viewed ${p}`);
  count.update((n) => n + 1);     // a write
});
// page = 'home', user = 'ann'. Then: user.set('bob'), tick, page.set('cart'), tick.
Show answer

Only page. The log is ['ann viewed home', 'bob viewed cart']: the user change alone didn't re-run the effect, but when page changed the effect read the current user. Writes don't create dependencies — and neither does update's internal read of the old value.

What’s happening
  1. On the first run the effect reads page() normally, so page becomes a dependency, and logs ann viewed home.
  2. untracked(user) reads the signal with tracking switched off. A signal is a function, so it can be passed straight in.
  3. count.update(...) writes count. Effects re-run when signals they read change, not ones they write, and update doesn't register count as a dependency.
  4. user.set('bob') notifies nobody relevant: nothing happens.
  5. page.set('cart') re-runs the effect. untracked(user) returns 'bob' — untracked means "don't subscribe", not "use an old value".
  6. The habit worth describing in an interview: read the signals you mean to react to at the top, then do the work inside untracked(() => …), so signals read by code you call (an analytics service, say) can't re-trigger the effect.

Learn more: untracked and effect() and when not to use it.

OnPush and mutation: what do the three paragraphs show after "mutate"?
TypeScript
// TodoCount: OnPush (the default), todos = input.required<string[]>(), shows {{ todos().length }} todos
// TodoCountEager: the same with changeDetection: ChangeDetectionStrategy.Eager
@Component({
  template: `
    <p>{{ todos.length }} in parent</p>
    <app-todo-count [todos]="todos" />
    <app-todo-count-eager [todos]="todos" />
    <button (click)="addByMutation()">mutate</button>
  `,
})
class Todos {
  todos = ['learn signals'];
  addByMutation() {
    this.todos.push('learn OnPush'); // same array reference
  }
}
Show answer

2 in parent, 1 todos (OnPush child) and 2 todos (Eager child). The click refreshed the parent, but the OnPush child's input got the same array reference, so it wasn't marked dirty and was skipped. The fix isn't Eager — it's an immutable update: todos = [...todos, item].

What’s happening
  1. The click is a template event in the parent, so the parent is marked dirty and refreshed: {{ todos.length }} shows 2.
  2. During that refresh Angular evaluates [todos]="todos" for each child and compares with the previous value. push didn't create a new array, so the binding is "unchanged" and the input signal isn't updated.
  3. The OnPush child has no new input, no event, no signal change and no markForCheck, so its view is skipped: still 1 todos.
  4. The Eager child is checked whenever its parent is refreshed, so its template re-reads the mutated array and shows 2. Same data, two screens.
  5. Replacing the array ([...this.todos, 'x']) changes the reference, updates the input and marks the OnPush child dirty: everyone shows 3. The same rule applies to signals and pure pipes — they all detect new references, not changed contents.

Learn more: The mutation problem and OnPush.

Zoneless: why is this button stuck on "Saving…"?
TypeScript
@Component({
  selector: 'app-save',
  template: `<button (click)="save()">{{ saving ? 'Saving…' : 'Save' }}</button>`,
})
class SaveButton {
  saving = false;
  async save() {
    this.saving = true;
    await fakeSave();
    this.saving = false;
  }
}
Show answer

Because the code after await runs in a promise continuation that nothing announces. Angular 22 is zoneless: the click handler's synchronous part is followed by a check (Saving… renders), but this.saving = false later is a plain property write with no signal, no event and no markForCheck. The test confirmed saving was false while the button still said Saving…. Make saving a signal.

What’s happening
  1. Angular wraps every template listener. After save() returns — at the first await, with a pending promise — Angular marks the view dirty and schedules a tick, so saving = true renders.
  2. When fakeSave() resolves, this.saving = false runs. Under zone.js the patched promise would have triggered a check; zoneless, nothing does.
  3. What does notify Angular in a zoneless app: a signal read by a template changing, a template or host listener firing, markForCheck() (which the async pipe calls), ComponentRef.setInput(), and attaching or removing views.
  4. Fix: saving = signal(false), this.saving.set(false), {{ saving() ? … }}. The signal version rendered Saving… then Save. markForCheck() after the await also works, but signals remove the problem instead of patching it.

Learn more: Zoneless Angular and zone.js and the old model.

What is the default change detection strategy in Angular 22, and what does Default mean now?
Show answer

OnPush. The ChangeDetectionStrategy docs in @angular/core 22 say "NOTE: OnPush is enabled by default". The old always-check strategy has been renamed Eager; ChangeDetectionStrategy.Default still exists with the same value as Eager, but it's deprecated — and, confusingly, no longer the default.

What’s happening
  1. A component with no changeDetection field compiles with ɵcmp.onPush === true; with ChangeDetectionStrategy.Eager it's false (checked in a test).
  2. The CLI agrees: ng generate component writes no changeDetection line, because OnPush is its default.
  3. An OnPush view is refreshed only when it's marked dirty: a new input reference, an event from its template, a signal its template read, or markForCheck().
  4. In a zoneless app Eager means "checked whenever the traversal reaches it", which only happens when its parent is refreshed. An Eager child under a refreshed parent picked up a plain-property change; the OnPush sibling didn't.
  5. In Rohit's Angular 16 project the default was always-check and OnPush was an opt-in optimisation. In 22 you'd add Eager only for legacy components that rely on mutation or unannounced property changes.

Learn more: OnPush and How change detection works.

markForCheck vs detectChanges: what does the DOM say on each line?
TypeScript
c.label = 'marked';
c.cdr.markForCheck();
text(f);                 // 1
await f.whenStable();
text(f);                 // 2

c.label = 'detected';
c.cdr.detectChanges();
text(f);                 // 3

The <p> started as start.

Show answer

start, marked, detected. markForCheck() only marks the component and its ancestors dirty and schedules a pass — nothing renders until that pass runs. detectChanges() runs change detection for this component and its children synchronously, right now.

What’s happening
  1. markForCheck() flags the view and asks the scheduler for a tick. The very next line still reads start.
  2. await f.whenStable() lets the scheduled pass run, and the DOM shows marked. Calling markForCheck() three times in a row still produced one pass — that batching is why it's the default choice.
  3. detectChanges() runs the update pass inside the call, so the next line already sees detected. It doesn't touch ancestors.
  4. Use markForCheck() (or better, a signal) when non-signal code changed state. Use detectChanges() when you need the DOM updated before your next line — measuring an element — or for a view you detach()ed on purpose, which ignores even signals until you check it yourself.

Learn more: markForCheck vs detectChanges.

Signal write vs markForCheck: how many times does each template run?
TypeScript
// Parent template: <h1>{{ renders() }}</h1><app-child />      renders() increments parentRenders
// Child template:  <p>{{ count() }} {{ renders() }}</p>         renders() increments childRenders
// Both OnPush (the default).
// 1. first render   2. child.count.set(1)   3. child.cdr.markForCheck()
Show answer

{ parentRenders: 1, childRenders: 1 }, then { parentRenders: 1, childRenders: 2 }, then { parentRenders: 2, childRenders: 3 }. A signal write refreshes only the view that read it; the parent is merely walked past. markForCheck() marks the component and every ancestor dirty, so the parent's template re-runs too.

What’s happening
  1. First render runs each template once: 1, 1.
  2. count.set(1) notifies the child's template. Angular flags the child view for refresh and marks the parent only "has a dirty descendant". The pass walks through the parent without re-running its template (parentRenders stays 1) and refreshes the child (childRenders = 2).
  3. markForCheck() (like a template event) marks the child and its ancestors dirty, so the parent's template runs (2) and then the child's (3).
  4. Scaled up, a signal change deep in a big page re-runs one small template instead of every template on the path to the root. The other half of the saving is computed: memoised, and it only notifies readers when its result actually changes.
  5. The renders() method is a probe for measuring, not something to ship — it's exactly the "method call in a template" this chapter warns about.

Learn more: Why signals make change detection cheaper.

#DI and RxJS questions

Dependency injection decides which instance of a service you get; RxJS decides how async values flow once you have it; and the HTTP layer is built on both. These questions cover the Angular 22 @Service decorator, provider recipes, the injection context, component-scoped instances, the four flattening operators, cold and hot observables, subjects, httpResource and interceptors. RxJS outputs come from real runs with timers; DI and HTTP results from the TestBed specs behind chapters 9 to 11 plus this page's own spec.

@Service vs @Injectable: what's the difference?
Show answer

@Service() is Angular 22's shorthand for the common case: it's provided in root automatically, exactly like @Injectable({ providedIn: 'root' }), and it enforces the modern style — the compiler rejects constructor injection with NG2028, so dependencies come from inject(). It adds autoProvided: false for services you scope yourself and a factory option. @Injectable still exists for constructor injection, the other providedIn scopes, inline useFactory/useClass, and all existing code.

What’s happening
  1. @Service() compiles to a provider definition with providedIn: 'root' — logging CartService.ɵprov.providedIn printed root. One lazy, tree-shakable singleton, created by the first inject().
  2. constructor(private http: HttpClient) {} in a @Service class fails: NG2028: @Service class cannot use constructor dependency injection. Use the `inject` function instead. Write private http = inject(HttpClient).
  3. @Service({ autoProvided: false }) compiles to providedIn: null: injectable, but nobody has it until you list it in some providers. Injecting it without a provider throws NG0201.
  4. @Service({ factory: () => … }) makes the class a typed token whose value comes from the factory — the chapter's BrowserStorage resolved to window.localStorage itself.
  5. ng generate service in CLI 22.2 emits @Service(); --injectable gives the old form. There's no need to migrate working @Injectable services.

Learn more: Services with @Service and inject() and Why @Injectable still exists.

useClass vs useExisting: same instance or not?
TypeScript
// NewLogger numbers every instance it creates: readonly id = ++created;
providers: [NewLogger, { provide: OldLogger, useClass: NewLogger }]
inject(OldLogger) === inject(NewLogger);   // A?

providers: [NewLogger, { provide: OldLogger, useExisting: NewLogger }]
inject(OldLogger) === inject(NewLogger);   // B?
Show answer

A is false (ids 1 and 2: two instances of the same class); B is true (one instance, id 1). useClass is a recipe that constructs a separate instance for its own token; useExisting is an alias that looks up the other token. If the class holds state, useClass silently splits it in two.

What’s happening
  1. Think of the injector as a table with one row per token. Both configs have a NewLogger → new NewLogger() row.
  2. useClass: NewLogger gives the OldLogger row its own "construct a NewLogger" recipe and its own cache. inject(OldLogger) builds instance 1; inject(NewLogger) builds instance 2. Both are instanceof NewLogger.
  3. useExisting: NewLogger makes the OldLogger row say "go and look up NewLogger". Both injections land on the same row and the same cached object; only one NewLogger is ever constructed.
  4. Use useExisting for "old token, same service" — migrations, exposing a service under an abstract token. Use useClass when you really want a different implementation or a separate instance.
  5. The other two recipes: useValue returns exactly the value you gave (a shared object if it's an instance), and useFactory runs once per injector, in an injection context.

Learn more: Provider strategies: useClass, useValue, useFactory, useExisting and Tokens, providers, injectors and instances.

Which inject() calls throw NG0203?
TypeScript
export class Profile {
  private readonly api = inject(UserApi);
  private readonly logger = inject(Logger);            // 1
  private readonly injector = inject(Injector);
  constructor() {
    inject(Logger).log('constructor ok');               // 2
    this.init();
  }
  async init() {
    await somePromise;
    const router = inject(Router);                      // 3
  }
  ngOnInit() {
    const users = toSignal(this.api.getUsers());        // 4
  }
  onSave() {
    inject(Logger).log('save');                         // 5
  }
  onSaveFixed() {
    runInInjectionContext(this.injector, () => inject(Logger).log('ok'));   // 6
  }
}
Show answer

3, 4 and 5 throw NG0203. inject() only works while DI is creating something — field initialisers, the constructor body, provider factories, functional guards/resolvers/interceptors, or inside runInInjectionContext. Lifecycle hooks, event handlers and code after an await all run after construction has finished.

What’s happening
  1. Lines 1 and 2 run during construction, when Angular has set the "current injector". They succeed.
  2. Line 3: the constructor calls init(), which runs synchronously up to the await and returns. When the promise resolves, construction is long over: "NG0203: The Router token injection failed. inject() function must be called from an injection context…".
  3. Line 4: ngOnInit is called by change detection, not by the injector. toSignal() checks for a context up front and throws its own NG0203 — fix it with a field initialiser or { injector: this.injector }. effect(), httpResource() and takeUntilDestroyed() with no argument follow the same rule.
  4. Line 5: an event handler runs long after construction, so inject() has no injector to read.
  5. Line 6 works: runInInjectionContext sets the captured injector as current for the duration of the callback. The everyday fix is simpler still — inject in a field and use the field later.

Learn more: The injection context and Where toSignal() can run.

How many CounterStore instances, and what do the labels show?
TypeScript
@Service({ autoProvided: false })
export class CounterStore { readonly id = ++storeCount; readonly count = signal(0); /* increment() */ }

@Component({ selector: 'app-counter-label', template: `<i>store #{{ store.id }} = {{ store.count() }}</i>` })
export class CounterLabel { protected readonly store = inject(CounterStore); }

@Component({
  selector: 'app-counter',
  imports: [CounterLabel],
  providers: [CounterStore],
  template: `<button (click)="store.increment()">+1</button> <app-counter-label />`,
})
export class Counter { protected readonly store = inject(CounterStore); }

// parent: <app-counter /> <app-counter />   — click the first button twice
Show answer

Two instances, and the labels read ['store #1 = 2', 'store #2 = 0']. A provider in a component's providers lives in that component instance's element injector, so every <app-counter> gets its own store, its children see it, and it's destroyed with the component.

What’s happening
  1. For each <app-counter> element Angular creates an element injector holding the CounterStore provider. The first Counter creates store #1, the second store #2.
  2. CounterLabel has no providers, so its inject(CounterStore) climbs to the nearest ancestor that has one — its own Counter — and gets that store.
  3. Two clicks on the first button take store #1 from 0 → 2. The second counter is untouched.
  4. Removing the second counter (@if turned off) destroyed its injector and ran store #2's ngOnDestroy — component-scoped services live and die with their component.
  5. autoProvided: false is what makes a missing provider fail loudly (NG0201) instead of silently sharing one root store. The classic interview trap is the reverse: adding a root CartService to one component's providers gives that subtree a fresh, empty cart.

Learn more: Component-level providers and Opting out of root providing: autoProvided false.

switchMap, mergeMap, concatMap, exhaustMap: when does each result arrive?

The user types a at 0 ms, ab at 50 ms and abc at 120 ms; each search request takes 100 ms. For each operator, which results reach the subscriber, and when?

Show answer
  • switchMap: only abc, at 220 ms — a and ab were cancelled.
  • mergeMap: a at 100, ab at 150, abc at 220 — all in parallel, in completion order.
  • concatMap: a at 100, ab at 200, abc at 300 — queued, one at a time, in order.
  • exhaustMap: a at 100, abc at 220 — ab was ignored because a was still running.
What’s happening
  1. All four map each value to an inner observable and subscribe to it for you. They differ only in what happens when a new value arrives while an inner one is still running.
  2. switchMap unsubscribes from the old inner, then starts the new one: at 50 ms request "a" cancelled, at 120 ms request "ab" cancelled. Latest wins — search, "load the selected item".
  3. mergeMap starts every request immediately and keeps them all. Results arrive as they finish — independent work like deletes and uploads (with a concurrent limit if there are many).
  4. concatMap holds ab in a queue until a completes at 100 ms, then abc until 200 ms. Order is guaranteed — ordered writes, autosave.
  5. exhaustMap drops ab because it arrived while busy; abc arrives at 120 ms, after a finished, so it starts. Ignore-while-busy — login and "pay now" buttons.
  6. Caveats worth adding: switchMap's cancel is an unsubscribe (the server may still process the request), concatMap's queue is unbounded, and in all four an uncaught inner error ends the outer stream.

Learn more: Choosing a flattening operator and switchMap.

Autosave: what's the final server state with mergeMap, and with concatMap?
JavaScript
const durations = { v1: 100, v2: 30 };
const save = (v) => timer(durations[v]).pipe(tap(() => (serverState = v)));
merge(at(0, 'v1'), at(10, 'v2'))
  .pipe(mergeMap(save)) // then try concatMap(save)
  .subscribe({ complete: () => log(`final server state: ${serverState}`) });
Show answer

With mergeMap the server ends with v1 — the older save; with concatMap it ends with v2. mergeMap runs both PUTs in parallel, so the slow first one finishes last and overwrites the newer value. concatMap waits for v1 to finish before sending v2.

What’s happening
  1. mergeMap: PUT v1 is sent at 0 ms (takes 100 ms), PUT v2 at 10 ms (takes 30 ms).
  2. v2 finishes at 40 ms → server has v2. v1 finishes at 100 ms → server has v1. The user's latest edit is lost.
  3. concatMap: PUT v1 at 0 ms, done at 100 ms; only then is PUT v2 sent, done at 130 ms → final server state: v2.
  4. switchMap would be wrong too: cancelling the client side of a write doesn't undo it on the server, and the save you dropped may still land. For writes, never switchMap; use concatMap when order matters, mergeMap when the writes are independent, exhaustMap to ignore duplicates.

Learn more: concatMap and mergeMap.

How many HTTP requests does this component send?
TypeScript
@Component({
  selector: 'app-greet',
  imports: [AsyncPipe],
  template: `<h1>{{ (user$ | async)?.name }}</h1>`,
})
class Greet {
  user$ = inject(UserApi).getUser(1); // this.http.get('/api/users/1')
  loggedName = '';
  constructor() {
    this.user$.subscribe((u) => (this.loggedName = u.name));
  }
}
Show answer

Two. HttpClient observables are cold: nothing is sent until someone subscribes, and every subscription runs the request again. The constructor's subscribe is one, the async pipe is another — this page's spec found two pending /api/users/1 requests in HttpTestingController. Subscribe once (toSignal, or one async pipe aliased with @if (user$ | async; as user)), or share with shareReplay({ bufferSize: 1, refCount: true }).

What’s happening
  1. user$ = getUser(1) only builds the recipe; assigning it sends nothing.
  2. The constructor's subscribe() executes the recipe: request 1.
  3. On the first render the async pipe subscribes to the same cold observable: request 2. Both got Ada when flushed.
  4. pipe() doesn't share either. Two maps off one http.get are two requests, and so are two | async usages on one field. Building pipelines shares code, not executions.
  5. Cold vs hot in one line: a cold observable (HTTP, timer, of) creates its producer per subscriber; a hot one (a Subject, DOM events, router events) has one producer that subscribers tap into. share()/shareReplay() make a cold observable hot by routing it through a Subject.

Learn more: Multiple subscriptions mean multiple executions and Hot vs cold observables.

Subject vs BehaviorSubject: who gets what?
JavaScript
const s = new BehaviorSubject(0);   // then try: new Subject()
s.next(1);
s.subscribe((v) => console.log('A', v));
s.next(2);
s.subscribe((v) => console.log('B', v));
s.next(3);
Show answer

With the BehaviorSubject: A 1, A 2, B 2, A 3, B 3. With a plain Subject: A 2, A 3, B 3. A Subject has no current value — late subscribers only see future values — so it models events. A BehaviorSubject requires an initial value, hands every new subscriber the current value immediately (synchronously, inside subscribe()), and exposes it as .value — so it models state.

What’s happening
  1. new BehaviorSubject(0) stores 0; next(1) replaces it with 1. Nobody is subscribed, but the value isn't lost.
  2. A subscribes and immediately gets the current value: A 1. next(2) reaches A: A 2.
  3. B subscribes late and immediately gets 2 — not 0 or 1; it keeps one value, not a history. next(3) reaches both.
  4. With a plain Subject, next(1) goes to an empty subscriber list and is gone. A only hears 2 and 3, B only 3.
  5. The classic Angular ≤ 16 store is a private BehaviorSubject exposed with asObservable() and updated immutably. In Angular 22 the same thing is usually a private signal with asReadonly() and computed().

Learn more: BehaviorSubject and Subjects.

share() vs shareReplay(): does a late subscriber trigger a new request?
JavaScript
const user$ = defer(() => { log(`request #${++executions}`); return timer(100).pipe(map(() => 'Ada')); });

const viaShare$ = user$.pipe(share());
viaShare$.subscribe((v) => log('share A:', v));                                // at 0 ms
timer(200).subscribe(() => viaShare$.subscribe((v) => log('share B (late):', v)));   // after completion

const viaReplay$ = user$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
timer(400).subscribe(() => viaReplay$.subscribe((v) => log('replay A:', v)));
timer(600).subscribe(() => viaReplay$.subscribe((v) => log('replay B (late):', v)));   // after completion
Show answer

The late share() subscriber does trigger a new request (request #2 at 200 ms); the late shareReplay subscriber doesn't — it gets the cached Ada instantly at 600 ms. share() resets when the source completes, so a later subscriber starts a fresh execution; shareReplay keeps the last value in a ReplaySubject and replays it.

What’s happening
  1. A subscribes to viaShare$ at 0 ms → request #1, Ada at 100 ms, then the source completes.
  2. share() defaults to resetOnComplete: true: it throws away its internal Subject. At 200 ms B looks like a brand-new first subscriber → request #2, answer at 300 ms.
  3. shareReplay at 400 ms → request #3, answer at 500 ms, value stored. At 600 ms the late subscriber gets Ada from the buffer, no request.
  4. refCount: true matters for sources that never complete: shareReplay(1) (the number-only form) keeps its source subscription alive with zero subscribers — the chapter's interval kept ticking forever. Make { bufferSize: 1, refCount: true } your default spelling.
  5. Rule of thumb: share() for live streams where latecomers should only see future values; shareReplay for "fetch once, give everyone the latest".

Learn more: Hot vs cold observables.

One search request returns a 500. Where should catchError go?
JavaScript
// A: searchBox$.pipe(switchMap(search), catchError(() => EMPTY))
// B: searchBox$.pipe(switchMap((q) => search(q).pipe(catchError(() => of(`no results for "${q}"`)))))
Show answer

Inside the flattening operator (B). In A the error propagates through switchMap, the whole pipeline errors, catchError replaces it with EMPTY, and the stream completes — the search box is unsubscribed and silently stops working after one failure. In B the error is caught on the inner request, which simply emits a fallback and completes, so the outer stream never sees an error.

What’s happening
  1. In the chapter's run, ab failed at 80 ms. With A the log ended 80ms outer catchError, 80ms complete — and there was no typed "abc" line at 120 ms, because the source had been unsubscribed.
  2. An errored observable is finished. catchError at the end can only swap the dead pipeline for another one; it can't resume the original.
  3. With B, switchMap just sees the ab inner emit no results for "ab" and complete. At 120 ms abc is typed, searched, and answered at 220 ms.
  4. Rule for every long-lived stream (search boxes, valueChanges, router events): put catchError inside the switchMap/mergeMap/concatMap/exhaustMap callback.
  5. Related: never subscribe() inside an operator. switchMap can only manage — and cancel — the observable you return.

Learn more: switchMap and Error handling with catchError and finalize.

httpResource: what happens when the template reads user.value().name and the server answers 404?
TypeScript
readonly id = input.required({ transform: numberAttribute });
readonly user = httpResource<User>(() => `/api/users/${this.id()}`);
// template: <h2>{{ user.value()?.name }}</h2>
Show answer

value() throws — for id 99: "Resource is currently in an error state (see Error.cause for details): Http failure response for /api/users/99: 404 Not Found". status() is 'error', error() holds the HttpErrorResponse and statusCode() is 404. Check error() or hasValue() before reading value().

What’s happening
  1. The reactive function reads this.id(), so the resource re-fetches whenever the id changes and cancels the previous request (the spec saw first.cancelled === true).
  2. The request is sent by an effect, so in a test you need TestBed.tick() before expectOne, and await fixture.whenStable() after flush for the value to settle.
  3. On a 404 the resource moves to 'error'. Reading value() in that state throws — ?. doesn't help, because the throw happens before .name.
  4. The safe template is @if (user.isLoading()) {…} @else if (user.error()) {…} @else if (user.hasValue()) { {{ user.value().name }} }. hasValue() returns false instead of throwing, and narrows the type.
  5. The other states: 'idle' when the function returns undefined, 'loading' for a new request (value cleared), 'reloading' after reload() (old value kept), 'resolved', and 'local' after set(). httpResource is for reads; do writes with HttpClient.

Learn more: httpResource and httpResource vs ngOnInit.

Interceptor order: predict the log
TypeScript
export const firstInterceptor: HttpInterceptorFn = (req, next) => {
  callLog.push('first: request');
  return next(req).pipe(tap((e) => callLog.push(`first: ${HttpEventType[e.type]}`)));
};
export const secondInterceptor: HttpInterceptorFn = (req, next) => {
  callLog.push('second: request');
  return next(req.clone({ setHeaders: { 'X-Second': 'yes' } })).pipe(
    tap((e) => callLog.push(`second: ${HttpEventType[e.type]}`)),
  );
};
// provideHttpClient(withInterceptors([firstInterceptor, secondInterceptor]))
// http.get('/api/ping').subscribe(() => callLog.push('subscriber: value')); then the backend flushes
Show answer
text
first: request
second: request
second: Sent
first: Sent
backend: flush
second: Response
first: Response
subscriber: value

Requests go through the array in order; events come back in reverse. The first interceptor is outermost: it sees the request first and the response last.

What’s happening
  1. subscribe() starts the chain. firstInterceptor logs and calls next(req), which invokes secondInterceptor.
  2. secondInterceptor clones the request — HttpRequest is immutable — adds X-Second, and passes the clone to the backend. The backend saw X-Second: yes.
  3. The surprise: interceptors see the whole event stream, and the first event is HttpEventType.Sent, emitted as soon as the request is dispatched. Response-only logic should filter with event.type === HttpEventType.Response or instanceof HttpResponse.
  4. The response travels back up: second first, then first, then HttpClient extracts the body for the subscriber.
  5. Interceptors run in an injection context, so inject(AuthService) works at the top of the function — but not inside a catchError callback that runs later. The old class-based HTTP_INTERCEPTORS form still works through withInterceptorsFromDi().

Learn more: Interceptors and An auth interceptor.

#Forms and routing questions

Forms and routing are where interview questions get practical: which form API you'd pick and why, the classic Reactive Forms traps (setValue, disabled controls, cross-field validators), template-driven gotchas, and the router's matching rules, component reuse, guards and lazy loading. Every answer below was checked in the specs behind chapters 6, 12 and 13, which type into real inputs and navigate with RouterTestingHarness.

Signal Forms, Reactive Forms or template-driven: what's the difference and which would you pick?
Show answer

The difference is where the source of truth lives. Signal Forms (stable in Angular 22): your data is a signal() and form() wraps it in a tree of reactive field state, bound with [formField]. Reactive Forms: an explicit, typed tree of FormControl/FormGroup/FormArray objects in the class owns the value, bound with [formGroup] + formControlName. Template-driven: ngModel directives in the template create the controls for you. For new Angular 22 code, Signal Forms; Reactive Forms for existing apps and RxJS-heavy forms; template-driven for very small forms.

What’s happening
  1. Each is a set of directives the component must import: FormField from @angular/forms/signals, ReactiveFormsModule, or FormsModule. Forget it and you get NG8002: Can't bind to 'formField' / 'formGroup' / 'ngModel'….
  2. Signal Forms have no second copy: this.model() and signupForm().value() are the same data. Rules are declarative schema functions (required(s.email), validate, applyWhen), state is signals (signupForm.email().touched()), and submit() handles touched-marking, submitting() and server errors.
  3. Reactive Forms are synchronous and explicit: the controls exist before the template does, valueChanges composes with RxJS, and conditional rules are imperative (setValidators, enable(), updateValueAndValidity()).
  4. Template-driven forms build the same FormControl tree from the template — asynchronously — and keep two copies: your model and the form value. Least code, least control, hardest to test.
  5. Practical differences people get asked about: disabled fields drop out of Reactive and template-driven .value but stay in a Signal Forms model; only Reactive and template-driven forms add ng-touched/ng-invalid classes by default.

Learn more: Three ways to build forms and Choosing a form approach.

setValue vs patchValue: which lines throw?
TypeScript
const pw = form.controls.passwords; // FormGroup { password, confirmPassword }

pw.patchValue({ password: 'secret123' });                        // 1
pw.setValue({ password: 'secret123' });                          // 2
pw.setValue({ password: 'a', confirmPassword: 'a', extra: 1 });  // 3
pw.setValue({ password: 'a', confirmPassword: 'b' });            // 4
Show answer

Lines 2 and 3 throw at runtime. setValue demands exactly the group's shape: line 2 fails with NG01002: Must supply a value for form control with name: 'confirmPassword'., line 3 with NG01001: Cannot find form control with name: 'extra'. patchValue updates the keys you pass, keeps the rest and silently ignores unknown keys.

What’s happening
  1. Line 1 walks the keys you gave and sets each matching child. confirmPassword keeps '', so the value is { password: 'secret123', confirmPassword: '' }.
  2. Line 2: setValue first checks that every control has a value in the argument. confirmPassword is missing, so it throws and writes nothing.
  3. Line 3: the same strictness in the other direction — an unknown key is an error.
  4. Line 4 has exactly the right shape and succeeds.
  5. With typed forms the compiler catches lines 2 and 3 before they run. Use patchValue to load a partial server response; use setValue when you want a crash if the shape drifts. In Signal Forms the same split is model.set(full) vs model.update(m => ({ ...m, name })).

Learn more: setValue vs patchValue.

A disabled control: what's in value and getRawValue()?
TypeScript
readonly signupForm = this.fb.group({        // NonNullableFormBuilder
  name: ['', Validators.required],
  plan: [{ value: 'free', disabled: true }],
});

What do signupForm.value and signupForm.getRawValue() contain, what's plan.valid, and how is this different in Signal Forms?

Show answer

value is { name: '' } — no plan key — while getRawValue() is { name: '', plan: 'free' }. A disabled control's status is 'DISABLED', so valid and invalid are both false and it's ignored by the group's validity. In Signal Forms a disabled field stays in the model — there's no getRawValue() because the model is the raw value.

What’s happening
  1. { value: 'free', disabled: true } is the "boxed value" form: the control starts disabled.
  2. A group's .value is built only from enabled children, so plan is skipped. That's why typed forms type .value as Partial<…>: any key might be missing at runtime.
  3. getRawValue() ignores disabled state, so its type is the full object. Use it when the backend expects every field, e.g. a PUT that sends the id back.
  4. plan.enable() brings it back into .value. Don't write [disabled]="true" on a reactive input — Angular warns "It looks like you're using the disabled attribute with a reactive form directive"; the control object owns disabled state. (In template-driven forms, [disabled] on an ngModel input is the right way.)
  5. In Signal Forms, disabled(s.plan, { when: () => 'reason' }) sets plan().disabled() and the disabled attribute, but signupForm().value().plan is still 'free'.

Learn more: Submitting, value and getRawValue and Conditional validation.

Signal Forms: is the field dirty, and what does reset() do?
TypeScript
signupForm.name().value.set('Via field');
signupForm.name().dirty();        // 1
// the user types "Rohit" into the name input and tabs out
signupForm.name().dirty();        // 2
signupForm.name().touched();      // 3
signupForm().reset();
signupForm.name().dirty();        // 4
this.model().name;                // 5
Show answer

false, true, true, false, 'Rohit'. Dirty means "the user changed the value through a bound control" — programmatic writes don't count. Touched means the user focused and then left the field. reset() clears touched and dirty on the whole tree but keeps the value.

What’s happening
  1. value.set('Via field') writes through the field into the model signal, but it isn't a user change, so dirty() stays false. That's what lets you load server data without the form looking edited.
  2. Typing fires input events; [formField] writes into the model synchronously and marks the field dirty.
  3. Tab fires blur, so the field is touched. While the user was still typing, touched() was false — which is why errors are gated on touched() && invalid(), not on dirty().
  4. reset() makes the tree pristine and untouched again.
  5. The model still says 'Rohit': to clear values too, pass them (reset(value)) or model.set(...) yourself. Compare Reactive reset() (back to initial values with NonNullableFormBuilder, null otherwise) and template-driven resetForm() (sets controls — and your two-way-bound model — to null).

Learn more: Field state: dirty, touched, valid and Reading the form value and fields.

Template-driven forms: three things that go wrong in this component
TypeScript
@Component({
  imports: [FormsModule],
  template: `
    <form #f="ngForm">
      <input name="first" [(ngModel)]="user.first" />
      <input [(ngModel)]="user.last" />
    </form>
  `,
})
export class Profile implements AfterViewInit {
  user = { first: 'Ada', last: 'Lovelace' };
  f = viewChild.required<NgForm>('f');
  ngAfterViewInit() { console.log(this.f().value); }
  clear() { this.f().resetForm(); }
}
Show answer

(1) The second input has no name, so creating the view throws NG01352: If ngModel is used within a form tag, either the name attribute must be set or the form control must be defined as 'standalone' in ngModelOptions. (2) Even with names, f.value read straight after the first render is {} — NgModel registers its control asynchronously. (3) resetForm() sets every control to null, and because [(ngModel)] writes back, your model becomes { first: null, last: null }.

What’s happening
  1. FormsModule attaches NgForm to every <form>; it owns a FormGroup. Each ngModel creates a FormControl and registers it in that group under the input's name attribute — not the property path. No name, no key, so it throws.
  2. Registration happens in a resolved promise, to avoid "changed after checked" errors while the form tree changes during rendering. In the chapter's spec, right after the first detectChanges(), ngForm.controls was {}; after await fixture.whenStable() all controls existed.
  3. The form value is keyed by name: with name="surname" on the second input, f.value is { first: 'Ada', surname: 'Lovelace' } while user keeps its own shape.
  4. resetForm() nulls the controls, and two-way binding pushes null into the model — code that later calls user.first.trim() crashes. Pass the initial values: resetForm({ first: '', last: '' }).
  5. One more Angular 22 trap: components are OnPush by default, so changing a plain-property model from code (not from typing) doesn't refresh the [ngModel] binding. Hold template-driven models in signals.

Learn more: Template-driven forms with ngModel and ngForm, validation and submit.

Where does a "passwords must match" validator go in Reactive Forms?
Show answer

On the parent FormGroup, because a validator only sees its own control and one field can't see its sibling. It reads both children with group.get(...), returns { passwordsMismatch: true } on the group, and the template checks the group's error. Group validators re-run whenever any child changes, so editing either field updates it. In Signal Forms, validate(s.confirmPassword, ({ value, valueOf }) => …) can read the other field and put the error on the confirm field itself.

What’s happening
  1. passwords: this.fb.group({ password: [...], confirmPassword: [''] }, { validators: [passwordsMatch] }) attaches the rule to the group.
  2. With secret123 / secret12, the group's errors was {"passwordsMismatch":true} while confirmPassword.errors was null. The error lives on the group, so the template reads signupForm.controls.passwords.hasError('passwordsMismatch'), gated on the confirm control being touched.
  3. If you put the validator on confirmPassword instead, it would only re-run when confirm changes — editing the password later wouldn't update the error.
  4. The other shapes interviewers ask about: a validator is just (control) => ValidationErrors | null; a factory like forbidden('admin') returns one (that's how Validators.minLength(2) works); an async validator returns an Observable or Promise, only runs once the sync validators pass, and leaves the control 'PENDING' while it runs.

Learn more: Custom validators and Validation in Signal Forms.

Snapshot vs reactive params: which user does the page show?
TypeScript
@Component({
  template: `<h2>User {{ id }}</h2><a routerLink="/users/2">Next user</a>`,
  imports: [RouterLink],
})
export class UserSnapshot {
  private route = inject(ActivatedRoute);
  id = this.route.snapshot.paramMap.get('id');
}
// route: { path: 'users/:id', component: UserSnapshot }

You're on /users/1 and click "Next user". What does the heading say, and how many UserSnapshot instances were created?

Show answer

The URL is /users/2 but the heading still says User 1, and only one instance was ever created. Navigating between URLs that match the same route reuses the component, so code that read the param once never runs again. Read params reactively — id = input.required({ transform: numberAttribute }) with withComponentInputBinding(), or route.paramMap — instead of the snapshot.

What’s happening
  1. /users/1 creates UserSnapshot; its field initialiser reads '1' from the snapshot and freezes it into id.
  2. /users/2 matches the same row with the same component. The default reuse strategy keeps the instance: the test asserted componentInstance was the same object and created was 1. No constructor, no ngOnInit.
  3. The router did update the ActivatedRoute — its paramMap observable emitted { id: '2' } and its snapshot was replaced — but nothing re-read it.
  4. With input binding, the router calls the equivalent of setInput('id', '2') on every navigation. Because it's a signal, computed(() => USERS.find(u => u.id === this.id())) re-derives automatically.
  5. Two follow-ups: params are always strings, hence numberAttribute (id === 42 is false for '42'); and a query param missing from the URL is bound as undefined, overriding the input's default.

Learn more: Route parameters and Route parameters as component inputs.

Route order and pathMatch: what does each URL show?
TypeScript
// Config A
[
  { path: 'users/:id', component: UserInput },
  { path: 'users/new', component: NewUser },
]
// Config B
[
  { path: '', redirectTo: 'home', pathMatch: 'prefix' },
  { path: 'home', component: Home },
  { path: 'about', component: About },
  { path: '**', component: NotFound },
]

What does /users/new show with A, and /about with B? What if B's first row has no pathMatch at all?

Show answer

A: User NaN — users/:id matches first and captures new as the id, so NewUser is unreachable. B: the URL becomes /home/about and shows NotFound, because every URL starts with ''. Without pathMatch, Angular refuses the config at startup with NG04014: Invalid configuration of route '{path: "", redirectTo: "home"}': please provide 'pathMatch'. The fix is pathMatch: 'full'.

What’s happening
  1. The router walks the array in order and takes the first route that can consume the URL. There's no "most specific" scoring.
  2. In A, :id matches any single segment, including new; numberAttribute('new') is NaN. Put literal paths before parameterised ones, and the ** wildcard last.
  3. In B, 'prefix' asks "does the URL start with ''?" — always yes. A prefix redirect replaces the matched part (nothing) with home and keeps the rest, so /about becomes /home/about, which matches no row except the wildcard.
  4. With pathMatch: 'full', '' only matches the bare /, which redirects to /home, and /about shows About.
  5. A myth to avoid: { path: '', component: Home } (a component route without children) above about did not swallow /about in the test. pathMatch matters on empty-path redirects and empty-path parents with children.

Learn more: Route order, wildcards and pathMatch.

A guard returns false vs a UrlTree: what happens, and what does navigateByUrl resolve to?
TypeScript
export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  if (auth.loggedIn()) return true;
  return inject(Router).createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
export const roleGuard = (role: string): CanActivateFn => () => inject(AuthService).roles().includes(role);
Show answer

Returning false cancels the navigation: you stay where you were and navigateByUrl resolves false. Returning a UrlTree cancels it and starts a new navigation there — logged out, /admin ended on /login?returnUrl=%2Fadmin — and, surprisingly, the original navigateByUrl('/admin') still resolves true, because the promise reports the outcome of the redirect. Prefer redirects: on an app's first navigation false leaves a blank outlet.

What’s happening
  1. A guard returns boolean | UrlTree | RedirectCommand, synchronously or as a Promise/Observable. inject() works inside it because the router runs guards in the route's injection context.
  2. authGuard builds a UrlTree with the target URL as returnUrl; a NavigationCancel event fires with "Redirecting to …" and a new navigation goes to /login.
  3. roleGuard('admin') is a factory returning the real guard. With no roles it returns false: navigateByUrl resolved false and the URL stayed /home.
  4. RedirectCommand is a redirect plus navigation options, e.g. new RedirectCommand(router.parseUrl('/login'), { skipLocationChange: true }) showed the login page while the address bar stayed /home.
  5. With several guards, results are taken in array order and the first non-true result by position wins, even if a later async guard finishes first. And guards are UX, not security: the API must check permissions too.

Learn more: Route guards and Navigating in code.

Lazy loading: how do you check it worked, and what silently breaks it?
TypeScript
{ path: 'reports', loadComponent: () => import('./lazy/reports') },
{ path: 'admin', canActivate: [authGuard], loadChildren: () => import('./lazy/admin.routes') },
Show answer

Run ng build and look for the "Lazy chunk files" table: admin-routes and reports appear there instead of in the initial chunks. It breaks silently when anything imports the file statically — one eager import Reports from './lazy/reports' moved the component into an initial chunk and left an 85-byte lazy stub. And canActivate doesn't stop the download: logged out, the admin chunk was still fetched; use canMatch to protect lazy code.

What’s happening
  1. import() tells the builder this file is only needed later, so it emits it as its own chunk. loadComponent lazy-loads one standalone component; loadChildren a whole routes array (Angular 16 style: import('./admin/admin.module').then(m => m.AdminModule)).
  2. With export default, the loader is just () => import('./x'); the router unwraps the default export.
  3. The router calls the loader on the first navigation and caches the result: a vi.fn loader was still at 1 call after leaving and coming back.
  4. Eager imports — a barrel file, a shared constant, a type-with-value — pull the code back into the initial bundle. The route still "lazy loads", but it loads nothing.
  5. The router loads child routes while matching, and canActivate runs after matching, so the chunk was downloaded before the redirect to /login. A failing canMatch means the row never matches, so the loader ran 0 times. canMatch replaced the deprecated canLoad.

Learn more: Lazy loading and Route guards.

#Lifecycle and testing questions

The lifecycle questions are where "I've used Angular" and "I understand Angular" separate: when inputs are set, which view runs which hooks, what content and view mean, and why some classic recipes stopped working once Angular went zoneless and OnPush by default. The testing questions follow from the same rules, because a TestBed fixture is just another zoneless application. Every log below is a real Vitest run in Angular 22.2 — from chapters 7, 15 and 16, plus this page's own spec.

Constructor vs ngOnInit: what does each see?
TypeScript
@Component({ selector: 'app-profile', template: `{{ userId() }}` })
export class Profile implements OnInit {
  userId = input.required<number>();
  theme = input('light');
  @Input() legacyName = 'default';

  constructor() { /* log theme(), legacyName, and try userId() */ }
  ngOnInit()    { /* log all three */ }
}
// parent: <app-profile [userId]="42" theme="dark" legacyName="Ada" />
Show answer
text
constructor: theme=light legacyName=default
constructor: userId threw NG0950: Input "userId" is required but no value is available yet.
ngOnInit: userId=42 theme=dark legacyName=Ada

The constructor is JavaScript, ngOnInit is Angular. The constructor runs before Angular has set any inputs, so you get defaults (or a throw for a required input); ngOnInit runs after the first time the inputs are set. Use the constructor for wiring (inject, signals, effect, render hooks) and ngOnInit for one-time work that needs inputs — or, with signal inputs, often neither: a computed over the input re-derives on every change.

What’s happening
  1. Creating the parent's view creates <app-profile> and runs its constructor. The parent's bindings haven't been evaluated yet.
  2. theme() returns the signal input's default and legacyName its field initialiser. Plausible values — which is what makes this bug sneaky.
  3. A required input has no default, so reading it throws NG0950. Written directly in the constructor or a field initialiser, the compiler rejects it first with NG8118: userId is a required input and does not have a value in this context.
  4. During the first check of the parent's view Angular sets all three inputs, then calls ngOnChanges (if there are bound inputs) and ngOnInit. Static attributes like theme="dark" count as input values too.
  5. The constructor is an injection context; ngOnInit isn't (inject() there throws NG0203). And ngOnInit runs once: when the router reuses the component for /users/2, it doesn't run again.

Learn more: Constructor vs ngOnInit and The constructor.

Predict the initial lifecycle order for a parent and a child
TypeScript
@Component({ selector: 'app-child', template: `{{ name() }}` })
class Child extends Logged { name = input(''); }

@Component({ selector: 'app-parent', imports: [Child], template: `<app-child [name]="name()" />` })
class Parent extends Logged { name = signal('Ada'); }
// Logged logs the constructor and all seven ng* hooks. Parent is created with TestBed.createComponent.
Show answer
text
Parent: constructor
Child: constructor
Parent: ngOnInit
Parent: ngDoCheck
Parent: ngAfterContentInit
Parent: ngAfterContentChecked
Child: ngOnChanges
Child: ngOnInit
Child: ngDoCheck
Child: ngAfterContentInit
Child: ngAfterContentChecked
Child: ngAfterViewInit
Child: ngAfterViewChecked
Parent: ngAfterViewInit
Parent: ngAfterViewChecked

Constructors first (no inputs yet). Then each component gets ngOnChanges (only with bound inputs) → ngOnInit → ngDoCheck → content hooks → view hooks, and a parent's ngAfterViewInit runs after all its children's. The parent has no ngOnChanges because nothing binds its inputs.

What’s happening
  1. createComponent builds the host view and Parent's view in the creation pass, so both constructors run before any hook.
  2. A component's hooks are run by the view that declares it. Parent's tag lives in the host view, so the host view's check runs Parent: ngOnInit, ngDoCheck and its content hooks.
  3. Then Parent's own template is refreshed. Reaching <app-child>, Angular sets name and runs Child's pre-order hooks: ngOnChanges (signal inputs trigger it too), ngOnInit, ngDoCheck, then Child's content hooks.
  4. Child's own view is rendered, then the view hooks for Parent's view run: Child: ngAfterViewInit/Checked.
  5. Back in the host view: Parent: ngAfterViewInit/Checked — last, after every child, which is why it's where decorator @ViewChild results are guaranteed. afterNextRender/afterEveryRender would come after all of this, once the whole app has rendered. (This exact log is from this page's spec; chapter 7 has the version with projected content.)

Learn more: The initial lifecycle order and Lifecycle interview traps.

ngOnChanges: which of these calls it?
TypeScript
// Price: amount = input(0, { transform: numberAttribute }); @Input() currency = 'USD';
// Cart template: <app-price amount="{{ amount() }}" [currency]="currency()" />
amount.set('25');                       // a
currency.set('USD');                    // b (same value)
currency.set('EUR'); amount.set('30');  // c (both, before one render)
price.currency = 'GBP';                 // d (assigned from code)
Show answer

a and c do; b and d don't. The log was amount 10→25, then one call with both keys amount 25→30 and currency USD→EUR. In Angular 22, ngOnChanges covers signal input()s as well as @Input()s, but it only fires when a binding (or setInput) delivers a value that's !== the previous one — one call per check, listing every input that changed.

What’s happening
  1. a: the binding re-evaluates to '25', the transform turns it into 25, and SimpleChange carries the transformed value: amount 10→25 (number).
  2. b: the binding gives 'USD' again, which === the previous value, so Angular doesn't call the hook at all.
  3. c: two signals change before the next render, so a single call receives both keys. One delivery note per check, not one per input.
  4. d: assigning from code bypasses the template — no ngOnChanges, and the OnPush view isn't even marked dirty, so the DOM keeps showing EUR.
  5. Mutations are invisible too: items.push(x) passes the same reference. For deriving values, a computed() over signal inputs replaces most ngOnChanges code; SimpleChanges<Price> gives you typed entries when you do need it.

Learn more: ngOnChanges and SimpleChanges and Deriving state with computed() instead of ngOnChanges.

Content vs view: what can the panel's queries see in ngOnInit, and is projected content created if it's never shown?
TypeScript
@Component({
  selector: 'app-panel',
  template: `
    <header #own>Panel header</header>
    <ng-content />
    @if (true) { <footer #inIf>footer</footer> }
  `,
})
export class Panel {
  projected = contentChild<ElementRef>('projected');
  own = viewChild<ElementRef>('own');
  inIf = viewChild<ElementRef>('inIf');
  @ViewChild('own') ownOld?: ElementRef;
}
// parent: <app-panel><p #projected>Projected</p></app-panel>
Show answer

In ngOnInit, the signal queries projected() and own() already find their elements, but inIf() is undefined and the decorator query ownOld is still undefined. Everything is visible by ngAfterViewInit. View is the component's own template; content is what the parent put between its tags. And yes — projected content is created by the parent even if the child never displays it.

What’s happening
  1. By ngOnInit the creation pass is done, so static elements exist. Signal queries are computed when read, so they find them; decorator queries are only assigned at fixed points — content queries by ngAfterContentInit, view queries by ngAfterViewInit.
  2. Elements inside @if/@for only exist after the view has been checked, so inIf() is undefined until ngAfterViewInit. A computed() over a signal query updates by itself when such a block appears.
  3. For one component the order is always ngAfterContentInit → ngAfterContentChecked → ngAfterViewInit → ngAfterViewChecked: content is checked before the view.
  4. Projected nodes belong to the parent: they bind the parent's data, are checked with the parent's view, and their hooks are run by the parent's view.
  5. So @if (open()) { <ng-content /> } controls visibility, not existence: in chapter 15's test a projected Heavy component logged Heavy constructed while the collapsible was closed, and was only destroyed with its parent. To create content on demand, take an <ng-template> and render it with ngTemplateOutlet.

Learn more: Content vs view, Content projection with ng-content and viewChild().

Which of these throws ExpressionChangedAfterItHasBeenCheckedError?
TypeScript
// All three: template `<p>{{ status }}</p>` (or status()), status starts 'loading',
// and ngAfterViewInit sets it to 'ready'. Dev mode.
StatusEager   // changeDetection: ChangeDetectionStrategy.Eager, status = 'loading'
StatusPlain   // default strategy (OnPush), status = 'loading'
StatusSignal  // default strategy, status = signal('loading'); ngAfterViewInit calls status.set('ready')
Show answer

Only StatusEager: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'loading'. Current value: 'ready'. StatusPlain has the same bug but shows no error — just a stale loading — because the dev-mode check skips OnPush views that aren't dirty. StatusSignal shows ready: a signal write during change detection schedules another render pass.

What’s happening
  1. In dev mode, after each change detection Angular runs a second, read-only pass (checkNoChanges) that re-evaluates the bindings it just rendered. A different value means the screen is already out of date, so it throws instead of silently showing stale data.
  2. StatusEager rendered 'loading', then ngAfterViewInit changed the field. The second pass sees 'ready' and throws; the DOM stays loading.
  3. StatusPlain is OnPush (the Angular 22 default). After rendering it's no longer marked for check, so the second pass skips it: no error, stale screen. provideCheckNoChangesConfig({ exhaustive: true }) makes the check see it — worth turning on in tests.
  4. StatusSignal writes a signal, which marks the view dirty, so Angular runs another pass and renders ready. Not a licence to write state in view hooks — it costs an extra render.
  5. Production skips the second pass, so there the bug is always a stale value. Fixes in order: derive with computed(), set the value earlier (field initialiser or ngOnInit), use a signal; setTimeout/detectChanges() workarounds hide the data-flow problem.

Learn more: ExpressionChangedAfterItHasBeenCheckedError.

takeUntilDestroyed: what's wrong with each subscription?
TypeScript
export class Ticker implements OnInit {
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    ticks.pipe(takeUntilDestroyed()).subscribe();                              // 1
    ticks.pipe(takeUntilDestroyed(this.destroyRef)).subscribe();               // 2
    clicks.pipe(takeUntilDestroyed(this.destroyRef), switchMap(() => polls)).subscribe(); // 3
  }
}
Show answer

1 throws NG0203: takeUntilDestroyed() can only be used within an injection context… — with no argument it calls inject(DestroyRef), and ngOnInit isn't an injection context. 2 is correct. 3 leaks: takeUntilDestroyed completes the stream only at the point where it sits, so switchMap keeps its inner polls subscription alive after destruction. Put it last, just before subscribe.

What’s happening
  1. takeUntilDestroyed() completes the stream when the component, directive or service that owns the DestroyRef is destroyed — it replaces the old destroy$ Subject + takeUntil + ngOnDestroy boilerplate.
  2. Without an argument it needs an injection context (constructor, field initialiser, factory). From ngOnInit, pass a DestroyRef captured in a field — that form works anywhere.
  3. In the chapter's test, destroying both components and then emitting on polls logged ['wrong 1', 'right 1', 'wrong 2']: the wrongly ordered pipeline still received 2 after destruction.
  4. On destroy, ngOnDestroy runs first (destroyRef.destroyed is already true inside it), then the DestroyRef callbacks in registration order. Registering on a destroyed DestroyRef throws NG0911: View has already been destroyed.
  5. Often you need none of this: toSignal() and the async pipe unsubscribe for you, HTTP requests complete on their own, and effect()s and render hooks are tied to the component's DestroyRef. Reach for takeUntilDestroyed when a subscription has a side effect.

Learn more: takeUntilDestroyed and ngOnDestroy and DestroyRef.

Why didn't the chat scroll? afterNextRender vs ngAfterViewChecked
TypeScript
export class Chat implements AfterViewChecked {
  messages = signal(['hi']);
  list = viewChild.required<ElementRef<HTMLUListElement>>('list');
  constructor() {
    afterRenderEffect({ write: () => { this.messages(); /* scroll list to bottom */ } });
  }
  ngAfterViewChecked() { /* scroll list to bottom */ }
}
// A WebSocket message arrives and the code runs messages.update(list => [...list, text]).
Show answer

ngAfterViewChecked doesn't run — only the afterRenderEffect does. A signal write refreshes just Chat's own view, and Chat's hooks belong to the view that declares it (its parent's), which isn't checked. afterRenderEffect re-runs after rendering because a signal it read changed. For DOM work after rendering, use the render hooks: afterNextRender (once), afterEveryRender, or afterRenderEffect.

What’s happening
  1. The test logged ngAfterViewChecked seeing [1] after the message, while afterRenderEffect saw [1, 2] and scrolled.
  2. A click inside the chat marks Chat's view and all its ancestors dirty, so the parent's view is checked and ngAfterViewChecked runs too. In zone.js-era apps almost every update looked like that, which is why the old recipe worked.
  3. ngAfterViewInit means "this component's view and its children have been checked" and runs inside change detection, on the server too. afterNextRender means "the whole application has rendered and the DOM is up to date", runs after change detection, never on the server, and is registered as a function in an injection context.
  4. Render hooks can be split into phases — earlyRead → write → mixedReadWrite → read — with each phase's return value passed to the next, to avoid layout thrashing.
  5. The same rule explains a related trap: a component whose own signals change gets no ngDoCheck or ngAfterViewChecked in zoneless Angular, but afterEveryRender runs after every application render.

Learn more: afterNextRender and afterEveryRender and ngAfterViewInit and ngAfterViewChecked.

Testing a component: why does fixture.detectChanges() not show the new name?
TypeScript
@Component({ selector: 'app-hello', template: `<p>Hello, {{ name }}</p>` })
class Hello { name = 'world'; }

const f = TestBed.createComponent(Hello);
await f.whenStable();
f.componentInstance.name = 'Rohit';
f.detectChanges();
f.nativeElement.textContent;   // ?
Show answer

It still says Hello, world. Hello is OnPush (the Angular 22 default), and a plain field assignment doesn't mark it dirty; fixture.detectChanges() checks from the host view and skips the clean OnPush component. Marking the component itself — f.componentRef.injector.get(ChangeDetectorRef).markForCheck() then await f.whenStable() — rendered Hello, Rohit. Better: use signals and setInput, and wait with await fixture.whenStable().

What’s happening
  1. A TestBed fixture is a small zoneless app. await fixture.whenStable() lets scheduled change detection run, the same way the app would.
  2. The assignment isn't a signal, an event, an input change or markForCheck, so nothing marks Hello dirty. detectChanges() walks the tree, finds an OnPush view that isn't dirty, and skips it.
  3. In chapter 16's spec, even fixture.changeDetectorRef.markForCheck() didn't help — that's the host view's change detector. Only the component's own ChangeDetectorRef did.
  4. The idiomatic way to drive a component: fixture.componentRef.setInput('label', 'Clicks') (which marks it dirty), click real DOM elements, then await fixture.whenStable() and assert on the DOM. Right after setInput the DOM still showed Count: 0; after whenStable() it showed Clicks: 0.
  5. setInput with a misspelt name fails loudly: NG0303: Can't set value of the 'lable' input on the '_Counter' component.

Learn more: Testing a component and OnPush.

How do you test an HTTP service, and why does expectOne find nothing?
TypeScript
TestBed.configureTestingModule({
  providers: [provideHttpClientTesting(), provideHttpClient()],
});
const ctrl = TestBed.inject(HttpTestingController);
TestBed.inject(UserApi).getUser(1).subscribe();
ctrl.expectOne('/api/users/1');   // fails: found none
Show answer

The provider order is swapped. provideHttpClientTesting() must come after provideHttpClient(...); otherwise the real backend wins and the request goes to the real fetch, never reaching the controller. With the right order: subscribe, expectOne(url) to find the pending request, check req.request, flush(body) to answer it, and verify() in afterEach to fail on unanswered requests.

What’s happening
  1. provideHttpClientTesting() swaps the real backend for HttpClientTestingBackend. Requests go through your real HttpClient and real interceptors, then wait in the controller until the test answers them.
  2. HttpClient observables are cold, so getUser(1) alone sends nothing — expectNone passes until you subscribe.
  3. expectOne with a string matches the full URL including the query string; use a predicate ((r) => r.url === '/api/users') to ignore params. A typo fails helpfully: "Expected one matching request for criteria "Match URL: /api/user/1", found none. Requests received are: GET /api/users/1."
  4. flush(body, { status: 404, statusText: 'Not Found' }) turns the response into an HttpErrorResponse; req.error(new ProgressEvent('error')) simulates no response (status 0); req.cancelled tells you a switchMap or httpResource unsubscribed.
  5. For signal-based HTTP (httpResource, toSignal), add TestBed.tick() after changing inputs (to send the request) and await fixture.whenStable() after flush (to let the value settle).

Learn more: Testing HTTP with HttpTestingController and Testing HTTP calls.

How do you unit-test a functional guard?
TypeScript
const route = {} as ActivatedRouteSnapshot;
const state = { url: '/admin' } as RouterStateSnapshot;
authGuard(route, state);   // ?
Show answer

Calling it directly throws NG0203: The `_AuthService` token injection failed…, because the guard calls inject() and a plain call has no injection context. Wrap it: TestBed.runInInjectionContext(() => authGuard(route, state)) returned a UrlTree that serialised to /login?returnUrl=%2Fadmin (and true once logged in). To test it through the router, use RouterTestingHarness.

What’s happening
  1. Functional guards are just functions, but the router runs them in the route's injection context. A unit test has to provide one, and TestBed.runInInjectionContext does.
  2. The result is an object you can assert on: toBeInstanceOf(UrlTree) and router.serializeUrl(result).
  3. Through the router: configure provideRouter([...routes]), const harness = await RouterTestingHarness.create(), then await harness.navigateByUrl('/admin') and check router.url (/login?returnUrl=%2Fadmin) and harness.routeNativeElement ("Please log in").
  4. The typed form harness.navigateByUrl('/admin', AdminDashboard) throws if something else activated: "Unexpected routed component type. Expected _AdminDashboard but got _Login". And only one harness is allowed per test.
  5. For canDeactivate with window.confirm, jsdom has no confirm, so the chapter's test stubbed it with vi.spyOn(window, 'confirm').mockReturnValue(false).

Learn more: Testing routes and guards and Route guards.

A test uses fakeAsync and tick() and fails under the Angular 22 CLI. Why, and how do you mock a dependency?
Show answer

New Angular 22 projects test with Vitest and no zone.js, and fakeAsync depends on zone.js: it throws zone-testing.js is needed for the fakeAsync() test helper but could not be found. Use Vitest's fake timers instead — vi.useFakeTimers() and await vi.advanceTimersByTimeAsync(ms). Mock dependencies through DI: { provide: UserApi, useValue: { getUser } } with getUser = vi.fn(), or vi.spyOn(realService, 'method').

What’s happening
  1. ng test in CLI 22 runs the @angular/build:unit-test builder with Vitest and jsdom. TestBed works the same, but the zone-based fakeAsync/tick helpers don't.
  2. The ch16 typeahead test: type three times 100 ms apart, advanceTimersByTimeAsync(299) — search not called — then one more millisecond, and search was called exactly once with 'ang'. debounceTime runs on the faked setTimeout.
  3. One catch with fake timers: await fixture.whenStable() can hang, because Angular's render is scheduled on a timer and the fake clock doesn't move by itself. Advance the clock (advanceTimersByTimeAsync, runAllTimersAsync) or render synchronously with fixture.detectChanges().
  4. useValue: { getUser } satisfies Partial<UserApi> replaces the whole service; getUser.mockReturnValue(of({ id: 7, name: 'Grace' })) controls each test, and toHaveBeenCalledExactlyOnceWith(7) asserts the call. This works without touching the component because it gets UserApi from DI — dependency inversion done by the framework.
  5. TestBed.overrideProvider replaces a provider later, but only before the first inject: afterwards it throws "Cannot override provider when the test module has already been instantiated."
  6. TestBed.overrideComponent(ProfilePage, { remove: { imports: [ActivityChart] }, add: { imports: [FakeActivityChart] } }) swaps a heavy child component for a fake one.

Learn more: The Angular testing setup, Mocks, spies and fakes and Dependency inversion for testing.

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.