Angular NotesRohit’s interview study guide
Chapter 07

Lifecycle Hooks

When Angular calls what — from constructor to ngOnDestroy, content vs view, and the modern DestroyRef and render hooks.

13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#The constructor

A component is a TypeScript class, and the constructor is the ordinary JavaScript class constructor. Angular calls it when it creates the instance — through the dependency-injection factory, during the creation pass of the template that contains the component's tag. At that moment the object exists and its injector exists, but Angular hasn't evaluated a single binding yet. Nobody has told the component anything.

A good mental model is a birth certificate: the component has been born and given its tools (everything inject() can hand it), but it hasn't received its instructions (inputs), and nothing it will display has been filled in. So the constructor is for wiring, not for work:

  • inject() calls and field initialisers (they run as part of construction too).
  • Creating signals, computed() values and effect()s.
  • Registering cleanup with DestroyRef, render callbacks with afterNextRender, and subscriptions with takeUntilDestroyed() — all of which need the injection context that only exists during construction (see The injection context).

What does not belong there: anything that reads an input, touches the component's DOM, or fetches data that depends on an input.

Here is what the canonical example of this chapter (a Parent that renders a Child with a component projected into it — the full code is in The initial lifecycle order) logs at the moment createComponent returns:

hooks-demo.spec.ts (excerpt)
const fixture = TestBed.createComponent(Parent);
expect(log.splice(0)).toEqual([
  'Parent: constructor',
  'Child: constructor',
  'Projected: constructor',
]);
What’s happening
  1. TestBed.createComponent(Parent) creates a host element <app-parent> and calls Parent's constructor first — the parent has to exist before its template can be created.
  2. Angular then runs the creation pass of Parent's template. It meets <app-child> and constructs Child, then meets <app-projected> (written by the parent between <app-child> tags) and constructs Projected.
  3. All three constructors have run, yet no lifecycle hook has run at all. ngOnChanges and ngOnInit only happen during the first change detection, which createComponent hasn't started.
  4. So inside any of these constructors, an input still has its default value, child components may not exist yet, and nothing has been rendered with real data.
AdvancedCompile error: required input in constructor

Reading a required signal input in the constructor is so clearly wrong that Angular 22 rejects it at compile time:

profile.ts
export class Profile {
  userId = input.required<number>();
  constructor() {
    console.log(this.userId()); // ❌ NG8118
  }
}
What’s happening
  1. input.required<number>() declares an input that must be bound by the parent; it has no default value.
  2. The constructor runs before any binding is evaluated, so there is nothing to read yet.
  3. The compiler spots the read and fails the build with NG8118: userId is a required input and does not have a value in this context.
  4. If you hide the read from the compiler (for example const read = this.userId; read()), it fails at runtime instead: NG0950: Input "userId" is required but no value is available yet. Both messages are real output from the sandbox.
AdvancedOlder syntax: constructor parameter injection

#ngOnInit

ngOnInit() is called once, after Angular has set the component's inputs for the first time — that is, right after the first ngOnChanges (if the component has any bound inputs), during the first change detection of the parent's view. It is the first moment when "my inputs are ready" is guaranteed.

The mental model: the parent's template is being checked top to bottom. When Angular reaches your tag it hands you your input values, and then it rings ngOnInit. It does not mean your template has been rendered. At this point your own view hasn't been checked yet, your child components haven't been initialised, and @if/@for blocks in your template don't exist yet (see Content vs view).

Use it for one-time initialisation that needs inputs: kicking off a load for userId, building a form from an input model, starting a subscription that depends on input values. The trap is the word once:

on-init-once.ts
@Component({
  selector: 'app-user-card',
  template: `<p>{{ nameOnInit }} | {{ nameComputed() }}</p>`,
})
export class UserCard implements OnInit {
  userId = input.required<number>();
  private store = inject(UserStore);

  nameOnInit = '';
  nameComputed = computed(() => this.store.nameFor(this.userId()));

  ngOnInit() {
    this.nameOnInit = this.store.nameFor(this.userId()); // runs ONCE
  }
}
AdvancedSpec: ngOnInit runs only once
on-init-once.spec.ts
const fixture = TestBed.createComponent(UserCard);
fixture.componentRef.setInput('userId', 1);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toBe('Ada | Ada');

fixture.componentRef.setInput('userId', 2);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toBe('Ada | Grace'); // ngOnInit value is stale
What’s happening
  1. setInput('userId', 1) sets the input as if a parent had bound [userId]="1". The first change detection then calls ngOnInit, which reads userId() → 1 and stores 'Ada' in the plain field nameOnInit.
  2. The template also reads nameComputed(). The computed runs lazily the first time it's read, calls nameFor(1) and caches 'Ada'. The page shows Ada | Ada.
  3. setInput('userId', 2) changes the input. The computed depends on userId, so it's marked stale and recomputes to 'Grace' the next time the template reads it.
  4. ngOnInit does not run again — it's a one-shot hook — so nameOnInit is still 'Ada'. The page shows Ada | Grace, which is exactly the "it works on first load but not when I navigate between users" bug.
  5. The fixes are to derive the value (computed, or resource/httpResource for async data — see Deriving state with computed() instead of ngOnChanges), or in older code to react in ngOnChanges.
AdvancedRouting pitfall: reused component instances

#Constructor vs ngOnInit

This is one of the most common interview questions, and the honest answer is short: the constructor is JavaScript, ngOnInit is Angular. The constructor runs when the object is created, before Angular has given it any data. ngOnInit runs later, when Angular has set the inputs for the first time. Everything else follows from that.

Proving it with a test that reads the same inputs in both places:

ctor-vs-init.ts
@Component({ selector: 'app-profile', template: `{{ userId() }}` })
export class Profile implements OnInit {
  userId = input.required<number>();
  theme = input('light');
  @Input() legacyName = 'default';

  constructor() {
    seen.push(`constructor: theme=${this.theme()} legacyName=${this.legacyName}`);
    try {
      const read = this.userId; seen.push(`constructor: userId=${read()}`);
    } catch (e) {
      seen.push(`constructor: userId threw ${(e as Error).message}`);
    }
  }

  ngOnInit() {
    seen.push(`ngOnInit: userId=${this.userId()} theme=${this.theme()} legacyName=${this.legacyName}`);
  }
}

@Component({
  selector: 'app-profile-host',
  imports: [Profile],
  template: `<app-profile [userId]="42" theme="dark" legacyName="Ada" />`,
})
export class ProfileHost {}
AdvancedInputs seen in constructor vs ngOnInit
Vitest output
constructor: theme=light legacyName=default
constructor: userId threw NG0950: Input "userId" is required but no value is available yet. Find more at https://v22.angular.dev/errors/NG0950
ngOnInit: userId=42 theme=dark legacyName=Ada
What’s happening
  1. Creating ProfileHost creates <app-profile> and runs Profile's constructor. The parent's bindings ([userId]="42", theme="dark", legacyName="Ada") haven't been evaluated yet.
  2. this.theme() returns the signal input's default, 'light', and this.legacyName is the decorator input's field initialiser, 'default'. Neither is wrong-looking, which is what makes this bug sneaky: you get a plausible value, just not the parent's.
  3. The required input userId has no default, so reading it throws NG0950. (The const read = this.userId indirection is only there to get past the compile-time NG8118 check shown in The constructor.)
  4. The first change detection of ProfileHost's view evaluates the bindings, sets all three inputs, and then calls ngOnInit. Now every input has the parent's value: 42, 'dark', 'Ada'.
  5. Note that theme="dark" and legacyName="Ada" are static attributes, not [bindings] — Angular still treats them as input values, set at the same moment.
AdvancedConstructor vs ngOnInit reference table
constructorngOnInit
Called byJavaScript (new), via Angular's DI factoryAngular, during the first check of the parent's view
Inputsdefaults only; required inputs throwset to the parent's values
Injection contextyes — inject(), DestroyRef, takeUntilDestroyed() workno — inject() throws NG0203
Runsonceonce
Use forwiring: inject, signals, computed, effect, render hooks, cleanupone-time work that needs inputs

#ngOnChanges and SimpleChanges

ngOnChanges(changes) is Angular telling you "some of your inputs just got new values — here are the before and after". It's called during the check of the parent's view, whenever at least one bound input received a value that is different (!==) from the previous one, and before ngOnInit on the first round. The changes argument is a SimpleChanges object with one SimpleChange per changed input: previousValue, currentValue, firstChange (and isFirstChange()).

Think of it as a delivery note: it only arrives when a delivery actually happened through the template (or setInput), and it lists every item delivered in that one check — not one note per item.

From my notes: "ngOnChanges (fired whenever input changes !!) – during initial changes ngOnChanges occurs before ngOnInit". Both true. The precise Angular 22 rule for signal inputs: ngOnChanges is called for them too. The OnChanges docs in Angular 22's core.d.ts say it covers "both signal-based and decorator-based inputs", and the test below proves it.

on-changes.ts
@Component({ selector: 'app-price', template: `{{ amount() }} {{ currency }}` })
export class Price implements OnChanges {
  amount = input(0, { transform: numberAttribute });
  @Input() currency = 'USD';
  calls: string[] = [];

  ngOnChanges(changes: SimpleChanges<Price>) {
    const a = changes.amount; // SimpleChange<number> | undefined
    const c = changes.currency; // SimpleChange<string> | undefined
    this.calls.push(
      Object.keys(changes).join('+') +
        (a ? ` amount ${a.previousValue}→${a.currentValue} (${typeof a.currentValue}) first=${a.firstChange}` : '') +
        (c ? ` currency ${c.previousValue}→${c.currentValue}` : ''),
    );
  }
}

@Component({
  selector: 'app-cart',
  imports: [Price],
  template: `<app-price amount="{{ amount() }}" [currency]="currency()" />`,
})
export class Cart {
  amount = signal('10');
  currency = signal('USD');
}
AdvancedngOnChanges spec excerpt
on-changes.spec.ts (excerpt)
fixture.componentInstance.amount.set('25');
await fixture.whenStable();
fixture.componentInstance.currency.set('USD'); // same value
await fixture.whenStable();
fixture.componentInstance.currency.set('EUR');
fixture.componentInstance.amount.set('30');
await fixture.whenStable();
price.currency = 'GBP'; // assigned from code, not through a binding
await fixture.whenStable();
AdvancedLogged SimpleChanges calls explained
price.calls
amount+currency amount undefined→10 (number) first=true currency undefined→USD
amount amount 10→25 (number) first=false
amount+currency amount 25→30 (number) first=false currency USD→EUR
What’s happening
  1. First check: both inputs are delivered for the first time, so one call arrives with both keys. previousValue is undefined and firstChange is true. Notice amount is the number 10: the parent passes the string '10' (interpolation always produces a string), the numberAttribute transform converts it, and SimpleChange carries the transformed value.
  2. amount.set('25'): only amount changed, so changes contains only amount: 10→25.
  3. currency.set('USD'): the binding re-evaluates to 'USD', which === the previous value, so Angular doesn't call ngOnChanges at all. No line in the log.
  4. Two sets, one check: both signals change before the next render, so a single call receives both keys. You get one delivery note per check, not one per input.
  5. price.currency = 'GBP': assigning from code bypasses the template, so there's no ngOnChanges — and the DOM still shows 30 EUR, because this OnPush component wasn't marked dirty either. Only bindings and setInput() count as input changes.
  6. SimpleChanges<Price> (Angular 22's generic form) types each entry: changes.amount is SimpleChange<number> | undefined, unwrapped from the signal input. Assigning changes.amount!.currentValue to a string fails with TS2322: Type 'number' is not assignable to type 'string'. The plain SimpleChanges is a loose string-keyed map.
AdvancedPitfall: mutated inputs and reference checks
AdvancedngOnChanges vs ngDoCheck

ngOnChanges vs ngDoCheck (from my notes): ngOnChanges is Angular's change detection for your inputs — it fires only when an input reference changed. ngDoCheck is your own change detection — it fires every time the parent's view is checked, changed or not, and you decide what counts as a change. Modern code rarely needs either: a computed() over signal inputs re-derives automatically (see Deriving state with computed() instead of ngOnChanges).

#The initial lifecycle order

From my notes: "Very important !!!! Initial lifecycle order". Here it is, not from a diagram but from a real Angular 22 test. Every component below logs every hook with its name, and the test asserts the exact log.

The setup: Parent's template renders <app-child> with two inputs — a signal input() and a decorator @Input() — and puts <app-projected> between the child's tags, so it's projected into the child's <ng-content>.

hooks-demo.ts
export const log: string[] = [];

@Directive()
export abstract class Logged implements OnChanges, OnInit, DoCheck, AfterContentInit,
  AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy {
  protected abstract label: string;

  // Called from each subclass constructor, after its `label` field is set.
  protected track() {
    log.push(`${this.label}: constructor`);
    inject(DestroyRef).onDestroy(() => log.push(`${this.label}: DestroyRef.onDestroy`));
    afterNextRender(() => log.push(`${this.label}: afterNextRender`));
    afterEveryRender(() => log.push(`${this.label}: afterEveryRender`));
  }
  ngOnChanges(changes: SimpleChanges) {
    const desc = Object.entries(changes)
      .map(([k, c]) => `${k} ${JSON.stringify(c.previousValue)}→${JSON.stringify(c.currentValue)} first=${c.firstChange}`)
      .join(', ');
    log.push(`${this.label}: ngOnChanges {${desc}}`);
  }
  ngOnInit() { log.push(`${this.label}: ngOnInit`); }
  ngDoCheck() { log.push(`${this.label}: ngDoCheck`); }
  ngAfterContentInit() { log.push(`${this.label}: ngAfterContentInit`); }
  ngAfterContentChecked() { log.push(`${this.label}: ngAfterContentChecked`); }
  ngAfterViewInit() { log.push(`${this.label}: ngAfterViewInit`); }
  ngAfterViewChecked() { log.push(`${this.label}: ngAfterViewChecked`); }
  ngOnDestroy() { log.push(`${this.label}: ngOnDestroy`); }
}

@Component({ selector: 'app-projected', template: `<i>projected</i>` })
export class Projected extends Logged {
  protected label = 'Projected';
  constructor() { super(); this.track(); }
}

@Component({
  selector: 'app-child',
  template: `<p>{{ name() }} / {{ legacy }}</p><ng-content />`,
})
export class Child extends Logged {
  name = input('');
  @Input() legacy = '';
  protected label = 'Child';
  constructor() { super(); this.track(); }
}

@Component({
  selector: 'app-parent',
  imports: [Child, Projected],
  template: `
    <app-child [name]="name()" [legacy]="legacy()">
      <app-projected />
    </app-child>
  `,
})
export class Parent extends Logged {
  name = signal('Ada');
  legacy = signal('v1');
  protected label = 'Parent';
  constructor() { super(); this.track(); }
}
What’s happening
  1. Logged is an abstract base class holding all eight hook methods, each pushing "<label>: <hook>" into a shared log array. The @Directive() decorator is required: without it the build fails with NG2007: Class is using Angular features but is not decorated.
  2. track() is called from each subclass constructor (after label has been assigned). It logs the constructor and registers three callbacks that aren't methods: a DestroyRef.onDestroy callback and the two render hooks. Those must be registered in the injection context, which is why they live in a method called from the constructor.
  3. Child has one signal input (name) and one decorator input (legacy), so we can compare how each one reaches ngOnChanges.
  4. Parent binds both inputs from signals and writes <app-projected /> inside <app-child>. Parent owns that element — it's in Parent's template — and Child only decides where it appears.
  5. All components use Angular 22's default change detection, which is OnPush (the ChangeDetectionStrategy docs in core.d.ts say "OnPush is enabled by default"). The app is zoneless.
AdvancedLifecycle order spec and diagram

The test drives it through an initial render, a change to each input, a no-op, and destruction:

hooks-demo.spec.ts
const fixture = TestBed.createComponent(Parent);
// → Parent, Child, Projected constructors (see "The constructor")

await fixture.whenStable(); // first change detection + render
expect(log.splice(0)).toEqual([
  'Parent: ngOnInit',
  'Parent: ngDoCheck',
  'Parent: ngAfterContentInit',
  'Parent: ngAfterContentChecked',
  'Child: ngOnChanges {name undefined→"Ada" first=true, legacy undefined→"v1" first=true}',
  'Child: ngOnInit',
  'Child: ngDoCheck',
  'Projected: ngOnInit',
  'Projected: ngDoCheck',
  'Projected: ngAfterContentInit',
  'Projected: ngAfterContentChecked',
  'Child: ngAfterContentInit',
  'Child: ngAfterContentChecked',
  'Projected: ngAfterViewInit',
  'Projected: ngAfterViewChecked',
  'Child: ngAfterViewInit',
  'Child: ngAfterViewChecked',
  'Parent: ngAfterViewInit',
  'Parent: ngAfterViewChecked',
  'Child: afterNextRender',
  'Child: afterEveryRender',
  'Projected: afterNextRender',
  'Projected: afterEveryRender',
  'Parent: afterNextRender',
  'Parent: afterEveryRender',
]);
Parent Child Projected 1 · create constructors 2 · host view Parent's own hooks 3 · Parent's view Child + Projected 4 · host view finishes Parent 5 · after render whole app rendered 1 constructor 2 constructor 3 constructor 4 ngOnInit 5 ngDoCheck 6 ngAfterContentInit 7 ngAfterContentChecked 8 ngOnChanges 9 ngOnInit 10 ngDoCheck 11 ngOnInit 12 ngDoCheck 13 ngAfterContentInit 14 ngAfterContentChecked 15 ngAfterContentInit 16 ngAfterContentChecked 17 ngAfterViewInit 18 ngAfterViewChecked 19 ngAfterViewInit 20 ngAfterViewChecked 21 ngAfterViewInit 22 ngAfterViewChecked 23 afterNext/EveryRender 24 afterNext/EveryRender 25 afterNext/EveryRender
The real Angular 22 log, phase by phase. Pre-order hooks (ngOnChanges/ngOnInit/ngDoCheck) run top-down; content and view hooks run bottom-up; render hooks run last, after the whole app.
What’s happening
  1. The key rule: a component's hooks are run by the view that declares it — the template where its tag is written — not by its own view. Parent's tag lives in the host view that TestBed (or bootstrapApplication) creates; Child and Projected are both written in Parent's template, so both are run by Parent's view. Projected is not run by Child, even though it ends up inside Child's DOM.
  2. Phase 2, host view. Angular checks the host view. Parent has no bound inputs, so there's no ngOnChanges at all; it gets ngOnInit and ngDoCheck. Then the host view's content hooks: Parent: ngAfterContentInit/Checked. Parent has no projected content, but the hooks still run.
  3. Phase 3, Parent's view. Angular refreshes Parent's template top to bottom. Reaching <app-child>, it sets both inputs and runs Child's pre-order hooks: ngOnChanges (one call, both inputs, first=true — the signal input is included), ngOnInit, ngDoCheck. Reaching <app-projected>, it runs ngOnInit and ngDoCheck for it (no inputs → no ngOnChanges).
  4. Still phase 3: content hooks, bottom-up. Angular registers content and view hooks when an element closes, and <app-projected> closes before </app-child>. So Projected: ngAfterContentInit/Checked comes before Child: ngAfterContentInit/Checked. Handy consequence: by the time Child's ngAfterContentInit runs, the content projected into it is already initialised.
  5. Still phase 3: child views, then view hooks. Angular now renders Child's template and Projected's template (their own views). Only then do the view hooks for Parent's view run, again bottom-up: Projected: ngAfterViewInit/Checked, then Child: ngAfterViewInit/Checked.
  6. Phase 4, host view again. Parent's own view (with everything inside it) is now complete, so the host view runs Parent: ngAfterViewInit/Checked. The parent's ngAfterViewInit is always after all its children's — that's why it's the safe place for decorator @ViewChild queries.
  7. Phase 5, after render. Only once the whole application has finished rendering do afterNextRender and afterEveryRender callbacks run (children first here; see afterNextRender and afterEveryRender). This is the order I verified by reading refreshView in Angular 22's runtime: template + pre-order hooks → embedded views → content queries + content hooks → child component views → view queries + view hooks → queue render callbacks.
AdvancedLifecycle on input changes and destroy

Then the input changes, the no-op and destruction:

hooks-demo.spec.ts (continued)
fixture.componentInstance.name.set('Grace'); // feeds the signal input()
await fixture.whenStable();
expect(log.splice(0)).toEqual([
  'Child: ngOnChanges {name "Ada"→"Grace" first=false}',
  'Child: ngDoCheck',
  'Projected: ngDoCheck',
  'Projected: ngAfterContentChecked',
  'Child: ngAfterContentChecked',
  'Projected: ngAfterViewChecked',
  'Child: ngAfterViewChecked',
  'Child: afterEveryRender',
  'Projected: afterEveryRender',
  'Parent: afterEveryRender',
]);

fixture.componentInstance.legacy.set('v2'); // feeds the @Input()
await fixture.whenStable();
expect(log.splice(0)[0]).toBe('Child: ngOnChanges {legacy "v1"→"v2" first=false}');

await fixture.whenStable(); // nothing changed
expect(log.splice(0)).toEqual([]);

fixture.destroy();
expect(log.splice(0)).toEqual([
  'Projected: ngOnDestroy',
  'Child: ngOnDestroy',
  'Child: DestroyRef.onDestroy',
  'Projected: DestroyRef.onDestroy',
  'Parent: ngOnDestroy',
  'Parent: DestroyRef.onDestroy',
]);
What’s happening
  1. name.set('Grace'). Parent's template reads name(), so this signal write marks Parent's view for refresh, and only that view is refreshed (zoneless Angular refreshes just the views whose signals changed). The *Init hooks never run again.
  2. Because only Parent's view is refreshed — not the host view — Parent's own ngDoCheck, ngAfterContentChecked and ngAfterViewChecked don't run. Parent's hooks belong to the host view (rule 1 above). This surprises people: a component's check hooks don't run when only its own signals changed.
  3. Inside Parent's view the usual order repeats with the *Checked hooks: Child gets ngOnChanges with only name (signal inputs do trigger ngOnChanges), then ngDoCheck. Projected gets ngDoCheck, ngAfterContentChecked and ngAfterViewChecked even though nothing about it changed — its hooks run because Parent's view was checked. Its own OnPush template is not re-rendered.
  4. legacy.set('v2') produces the same sequence, with ngOnChanges {legacy "v1"→"v2"}. In Angular 22 the decorator @Input() and the signal input() behave the same for ngOnChanges.
  5. No changes: whenStable() with nothing dirty runs no change detection, so no hook at all — not even ngDoCheck or afterEveryRender. With zoneless there's no "tick" unless something notifies Angular.
  6. Destroy: ngOnDestroy runs bottom-up (Projected, then Child, then Parent). For each component, its DestroyRef callbacks run right after the component's own view is torn down; here Child's DestroyRef callback ran before Projected's even though Projected's ngOnDestroy ran first. Don't build logic on the order between siblings — only on "children before parent".
AdvancedOlder behaviour: zone.js and Default strategy
Predict the order: a click in one of two siblings

Top's template is <app-kid /><app-kid />. Each Kid has a <button (click)="clicks = clicks + 1">{{ clicks }}</button>. Both use the same logging base class. After the first render you click the first Kid's button. Which hooks run, in what order, and what text does the page show?

Show answer

The real log:

text
Top: ngDoCheck
Top: ngAfterContentChecked
Kid: ngDoCheck
Kid: ngDoCheck
Kid: ngAfterContentChecked
Kid: ngAfterContentChecked
Kid: ngAfterViewChecked
Kid: ngAfterViewChecked
Top: ngAfterViewChecked
Kid: afterEveryRender
Kid: afterEveryRender
Top: afterEveryRender

The page shows 10.

What’s happening
  1. A DOM event handled by a template marks that view and all its ancestors dirty, up to the root. That's different from a signal write, which only marks the view that read it. So this time the host view is refreshed too, and Top's own ngDoCheck and ngAfterContentChecked run.
  2. Top's view is refreshed. Both Kids are declared there, so both get ngDoCheck — including the second Kid, which wasn't clicked.
  3. Hooks are grouped by phase, not by component: both Kids' ngDoCheck, then both ngAfterContentChecked, then (after the child views are refreshed) both ngAfterViewChecked. It's never "Kid 1: all hooks, then Kid 2: all hooks".
  4. The first Kid's view is dirty and re-renders (1). The second Kid is OnPush and not dirty, so its view is skipped and still shows 0 — its ngDoCheck ran anyway. That's why the text is 10.
  5. Then the host view finishes with Top: ngAfterViewChecked, and finally the render hooks run for everyone.

#ngDoCheck

ngDoCheck() is a hook for custom change detection: Angular calls it every time it checks the view that declares your component, so you can compare whatever you like and decide whether something changed. It exists for the cases Angular's own input check can't see — classically, a mutated array or object input (same reference, new contents).

The mental model: ngDoCheck is a doorbell, not a delivery. It rings whenever Angular walks past your door (checks the parent's view), whether or not anything arrived for you. It even rings when your own OnPush view is about to be skipped. That's what makes it both useful and dangerous: it runs very often, so everything inside it must be cheap.

From my notes: "Be very careful with ngDoCheck…". Here is the classic mutation problem and the classic ngDoCheck fix:

do-check.ts
@Component({
  selector: 'app-tag-count',
  template: `{{ tags.length }} tags`,
})
export class TagCount {
  @Input() tags: string[] = [];
}

@Component({
  selector: 'app-tag-count-docheck',
  template: `{{ tags.length }} tags`,
})
export class TagCountDoCheck implements DoCheck {
  @Input() tags: string[] = [];
  private cdr = inject(ChangeDetectorRef);
  private lastLength = 0;
  doCheckCalls = 0;

  ngDoCheck() {
    this.doCheckCalls++;
    if (this.tags.length !== this.lastLength) { // cheap comparison only!
      this.lastLength = this.tags.length;
      this.cdr.markForCheck(); // re-render this OnPush view in this pass
    }
  }
}

@Component({
  selector: 'app-tag-editor',
  imports: [TagCount, TagCountDoCheck],
  template: `
    <button (click)="add()">add</button>
    <app-tag-count [tags]="tags" />
    <app-tag-count-docheck [tags]="tags" />
  `,
})
export class TagEditor {
  tags = ['angular'];
  add() {
    this.tags.push('signals'); // mutation: same array reference
  }
}
AdvancedSpec: catching mutation with ngDoCheck
do-check.spec.ts (excerpt)
expect([plain(), withDoCheck()]).toEqual(['1 tags', '1 tags']);

el.querySelector('button')!.click();
await fixture.whenStable();

expect([plain(), withDoCheck()]).toEqual(['1 tags', '2 tags']); // plain one is stale
expect(dc.doCheckCalls).toBe(2); // initial render + the click
What’s happening
  1. Initially both children receive the same one-element array and render 1 tags. TagCountDoCheck.ngDoCheck ran once (first render) and stored lastLength = 1.
  2. The click calls add(), which pushes into the existing array. The [tags] binding still evaluates to the same reference, so Angular sees no input change: no ngOnChanges, and neither OnPush child is marked dirty.
  3. The click did mark TagEditor's view dirty, so Angular checks it — and that's where both children's ngDoCheck would be called (only the second one implements it).
  4. TagCountDoCheck.ngDoCheck compares 2 !== 1, stores the new length and calls markForCheck(). That marks its own view dirty during this pass, so when Angular reaches its view a moment later it re-renders: 2 tags.
  5. TagCount has no such check, so Angular skips its OnPush view and it keeps showing 1 tags — a stale UI with no error.
  6. The comparison is only a length check on purpose. A deep comparison of a large object on every ngDoCheck is the classic way to make an Angular app slow. The real fix is to stop mutating: pass a new array (this.tags = [...this.tags, 'signals']) or keep the list in a signal (see The mutation problem).
AdvancedWhen ngDoCheck runs and when it doesn't

From my notes, "Important point about change detection and ngDoCheck()" — the two facts that matter, both visible in the sibling quiz in The initial lifecycle order:

  • ngDoCheck runs for an OnPush component even when its view is going to be skipped. It's called by the parent's check, before Angular decides whether to refresh the child.
  • It does not run when only the component's own signals changed, because then Angular refreshes just that component's view, and the component's hooks belong to its parent's view. So in a signals-based app, ngDoCheck is not a reliable "something changed" hook — use computed() or effect() for that.
AdvancedPerformance pitfall: expensive ngDoCheck

#Content vs view

From my notes: "Content vs View (!!!!)". These two words decide which hooks and which queries you use, so get the distinction crisp:

  • View — the component's own template. You wrote it, it's in template:, and only you can see into it. Queried with viewChild()/viewChildren(); ready by ngAfterViewInit.
  • Content — what the parent put between your opening and closing tags, displayed through <ng-content>. The parent wrote it, the parent's data binds it, and the parent's view runs its hooks. Queried with contentChild()/contentChildren(); ready by ngAfterContentInit.

An analogy: a picture frame (the component) has its own carved border (its view), and whoever buys it slides in a photo (the content). The frame decides where the photo goes, but it didn't take the photo.

The content is checked before the view, so for one component the order is always ngAfterContentInit → ngAfterContentChecked → ngAfterViewInit → ngAfterViewChecked. *Init runs once; *Checked runs after every check. This test logs which queries can see what at each moment, for both signal queries and decorator queries:

content-vs-view.ts
@Component({
  selector: 'app-panel',
  template: `
    <header #own>Panel header (the panel's own VIEW)</header>
    <ng-content />
    @if (true) { <footer #inIf>footer inside an if block</footer> }
  `,
})
export class Panel implements OnInit, AfterContentInit, AfterViewInit {
  // Signal queries
  projected = contentChild<ElementRef>('projected');
  own = viewChild<ElementRef>('own');
  inIf = viewChild<ElementRef>('inIf');
  // Decorator queries (older style)
  @ContentChild('projected') projectedOld?: ElementRef;
  @ViewChild('own') ownOld?: ElementRef;
  @ViewChild('own', { static: true }) ownStatic?: ElementRef;

  constructor() { this.report('constructor'); }
  ngOnInit() { this.report('ngOnInit'); }
  ngAfterContentInit() { this.report('ngAfterContentInit'); }
  ngAfterViewInit() { this.report('ngAfterViewInit'); }
  // report() logs yes/no for: projected()/projectedOld, own()/ownOld, ownStatic, inIf()
}

@Component({
  selector: 'app-panel-host',
  imports: [Panel],
  template: `
    <app-panel>
      <p #projected>Projected paragraph (the panel's CONTENT)</p>
    </app-panel>
  `,
})
export class PanelHost {}
AdvancedQuery visibility at each lifecycle hook
Vitest output (signal / decorator)
constructor        content: no/no  view: no/no  static view: no  @if view: no
ngOnInit           content: yes/no  view: yes/no  static view: yes  @if view: no
ngAfterContentInit content: yes/yes  view: yes/no  static view: yes  @if view: no
ngAfterViewInit    content: yes/yes  view: yes/yes  static view: yes  @if view: yes
What’s happening
  1. Constructor: nothing is visible. Even the panel's own <header> doesn't exist yet — the panel's view is created right after its constructor returns.
  2. ngOnInit: the panel's view and the parent's content nodes have been created (the creation pass is done) but not checked. Signal queries are computed when you read them, so projected() and own() already find the static elements. Decorator queries are only assigned at fixed points, so projectedOld/ownOld are still undefined. { static: true } is the old opt-in for "resolve before ngOnInit", which only works for elements outside @if/@for.
  3. ngAfterContentInit: content queries are now resolved for decorators too (projectedOld is set). The panel's own view hasn't been checked, so the decorator view query is still undefined, and the @if block hasn't been created — inIf() is undefined even as a signal query.
  4. ngAfterViewInit: the panel's view has been checked, the @if block exists, and every query sees everything.
  5. The rule to remember: elements inside @if/@for only exist after the view is checked, so their queries are only reliable from ngAfterViewInit (or in a computed/afterNextRender). Signal queries are better because they're reactive — a computed() over inIf() updates by itself when the block appears or disappears — but they can't see an element before it exists.
AdvancedProjected content belongs to the parent

"1 more thing" from my notes, and it's the one people miss: projected content belongs to the parent. Its bindings use the parent's data, it's checked as part of the parent's view, and its lifecycle hooks are run by the parent's view. That's why in the canonical log Projected's hooks are interleaved with Child's as a sibling, not nested inside Child's. It also means the parent creates projected content even if the child never displays it — see Content projection with ng-content.

#ngAfterViewInit and ngAfterViewChecked

ngAfterViewInit() runs once, after the component's own view and all its child views have been created and checked for the first time. It's the first moment when everything in your template is guaranteed to exist — so it's where decorator @ViewChild queries were traditionally read, and where code that needs the component's DOM (measuring, focusing, handing an element to a chart library) used to go. ngAfterViewChecked() runs after every check of the view.

From my notes: "ngAfterViewChecked (Scroll messages chat as an e.g.)" — the textbook use is "after new messages are rendered, scroll to the bottom". The DOM has been updated at that point, so scrollHeight already includes the new message. In Angular 22, though, the test below shows why this example now belongs to a render hook instead:

chat.ts
@Component({
  selector: 'app-chat',
  template: `
    <ul #list>
      @for (m of messages(); track $index) { <li>{{ m }}</li> }
    </ul>
    <button (click)="send('clicked')">send</button>
  `,
})
export class Chat implements AfterViewChecked {
  messages = signal(['hi']);
  list = viewChild.required<ElementRef<HTMLUListElement>>('list');
  viewChecked: number[] = []; // <li> count seen by ngAfterViewChecked
  renderEffect: number[] = []; // <li> count seen by afterRenderEffect

  constructor() {
    // Modern: re-runs after rendering whenever a signal it read has changed
    afterRenderEffect({
      write: () => {
        this.messages(); // dependency
        const ul = this.list().nativeElement;
        this.renderEffect.push(ul.children.length);
        ul.scrollTop = ul.scrollHeight;
      },
    });
  }

  // Classic: runs whenever Angular checks the view that DECLARES <app-chat>
  ngAfterViewChecked() {
    this.viewChecked.push(this.list().nativeElement.children.length);
  }

  send(text: string) {
    this.messages.update((list) => [...list, text]);
  }
}

@Component({ selector: 'app-chat-page', imports: [Chat], template: `<app-chat />` })
export class ChatPage {}
AdvancedSpec: chat auto-scroll after render
chat.spec.ts
const fixture = TestBed.createComponent(ChatPage);
await fixture.whenStable();
const chat = fixture.debugElement.query(By.directive(Chat)).componentInstance as Chat;
expect(chat.viewChecked).toEqual([1]);
expect(chat.renderEffect).toEqual([1]);

chat.send('hello'); // signal write from code (e.g. a WebSocket message)
await fixture.whenStable();
expect(chat.viewChecked).toEqual([1]); // ❗ did not run
expect(chat.renderEffect).toEqual([1, 2]);

fixture.nativeElement.querySelector('button').click(); // DOM event inside the chat
await fixture.whenStable();
expect(chat.viewChecked).toEqual([1, 3]);
expect(chat.renderEffect).toEqual([1, 2, 3]);
What’s happening
  1. First render: both hooks run once and see one <li>. ngAfterViewChecked runs as part of ChatPage's view check; the afterRenderEffect runs after the app has rendered (it always runs at least once).
  2. send('hello') from code — say, a WebSocket message. The signal write marks only Chat's view (its template reads messages()), so Angular re-renders just that view. ChatPage's view isn't checked, and Chat's hooks belong to ChatPage's view, so ngAfterViewChecked doesn't run. The chat would not scroll.
  3. The afterRenderEffect read messages(), so the write made it dirty. After the render it re-runs, sees 2 <li>s and scrolls. It runs because its data changed, not because some view happened to be checked.
  4. A click inside the chat marks Chat's view and every ancestor dirty, so ChatPage's view is checked and ngAfterViewChecked runs too (seeing 3 <li>s). In zone.js-era apps nearly every update looked like this, which is why the ngAfterViewChecked scroll recipe used to work.
  5. The DOM write in both versions is a pure DOM operation (scrollTop), not a change to template state. Changing a bound field here instead would trigger the error in the next topic.
AdvancedAfter-view hook rules for Angular 22

"Notes worth keeping…" from my notes, updated for Angular 22:

  • ngAfterViewInit is still the place where decorator @ViewChild results are guaranteed. With signal viewChild() you usually don't need the hook: read the query wherever you need it (in a computed, an event handler, afterNextRender).
  • Don't change state that the template binds to inside ngAfterViewInit/ngAfterViewChecked. The view has already been checked with the old value.
  • For DOM work after rendering, prefer afterNextRender (once) or afterRenderEffect/afterEveryRender (repeatedly). They run after the whole app has rendered and never on the server (see afterNextRender and afterEveryRender).

#ExpressionChangedAfterItHasBeenCheckedError

From my notes: "Angular also warns against changing state casually inside the content/view checked/init hooks because you can cause the famous" — this error. It's a development-mode error only. After every change detection in dev mode, Angular runs a second, read-only pass (checkNoChanges) that re-evaluates the bindings it just rendered. If any binding now gives a different value, the first pass rendered something that's already out of date, so Angular throws NG0100 instead of silently showing stale data. Production builds skip the second pass, so there the bug shows up as a stale screen.

The mental model: Angular photographs each binding as it renders it, then in dev mode takes a second photo to check the subject didn't move. Changing a bound value after its photo was taken — typically in ngAfterViewInit, ngAfterViewChecked or a child's hook that writes to the parent — makes the photos differ.

expression-changed.ts
@Component({
  selector: 'app-status-eager',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: `<p>{{ status }}</p>`,
})
export class StatusEager implements AfterViewInit {
  status = 'loading';
  ngAfterViewInit() {
    this.status = 'ready';
  }
}

@Component({
  selector: 'app-status-plain', // default strategy in Angular 22: OnPush
  template: `<p>{{ status }}</p>`,
})
export class StatusPlain implements AfterViewInit {
  status = 'loading';
  ngAfterViewInit() {
    this.status = 'ready'; // the <p> was already checked with 'loading'
  }
}

@Component({
  selector: 'app-status-signal',
  template: `<p>{{ status() }}</p>`,
})
export class StatusSignal implements AfterViewInit {
  status = signal('loading');
  ngAfterViewInit() {
    this.status.set('ready'); // a signal write schedules another pass instead
  }
}
AdvancedNG0100 under Eager vs OnPush
Test results (dev mode, condensed)
StatusEager:  NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'loading'. Current value: 'ready'. Expression location: _StatusEager component. Find more at https://v22.angular.dev/errors/NG0100
              DOM: loading
StatusPlain:  no error — DOM: loading
StatusSignal: no error — DOM: ready
What’s happening
  1. StatusEager uses ChangeDetectionStrategy.Eager — what older Angular called Default, which is now a deprecated alias for it. Rendering evaluates {{ status }} → 'loading' and writes it to the <p>. Then the view hooks run and ngAfterViewInit sets status = 'ready'.
  2. The dev-mode checkNoChanges pass re-evaluates {{ status }}, gets 'ready', compares it with the recorded 'loading' and throws the message above (quoted exactly; the test build printed the class name as _StatusEager). The DOM still says loading. fixture.whenStable() rejected with it, and so did a later fixture.detectChanges().
  3. StatusPlain has the same bug, but with Angular 22's default OnPush there's no error — just a stale loading on screen. The second pass only checks views that are marked for check, and an OnPush view isn't marked anymore after it's been rendered. Angular's own provideCheckNoChangesConfig docs say it: the check "does not work for components using ChangeDetectionStrategy.OnPush".
  4. Adding provideCheckNoChangesConfig({ exhaustive: true }) (developer preview in 22) to the test's providers makes the second pass check every view as if it were Eager, and StatusPlain then throws the same NG0100 (Expression location: _StatusPlain component). Worth turning on in tests.
  5. StatusSignal writes a signal. A signal write during change detection marks the view dirty, so Angular simply runs another render pass and the screen shows ready, with no error. That's not a licence to write state in view hooks (every such write costs an extra render pass), but it explains why signal-based code rarely sees NG0100.
AdvancedFixing NG0100 in order of preference

How to fix it, in order of preference:

  • Derive instead of set. If a value depends on something else, make it a computed(). Most NG0100s are a value being "synced" in a hook.
  • Set it earlier. Values known up front belong in a field initialiser, the constructor or ngOnInit — before the view is rendered.
  • Use a signal so the change schedules a new render instead of contradicting the old one.
  • The old workarounds — setTimeout(() => this.x = …), Promise.resolve().then(…), or cdr.detectChanges() right after the change — push the update into another pass. They work, but they hide a data-flow problem and cause a flicker.

#ngOnDestroy and DestroyRef

ngOnDestroy() runs once, just before Angular removes a component, directive, pipe or service from the app — when an @if turns false, an @for item disappears, the router navigates away, or a component-level injector is destroyed. It's your last chance to clean up anything Angular doesn't own: subscriptions, timers, manual event listeners, WebSocket connections, third-party widgets.

DestroyRef is the modern, composable version of the same hook. Instead of implementing a method, you inject(DestroyRef) and register callbacks with onDestroy(fn). The scope depends on where it's injected: in a component or directive it fires when that instance is destroyed; in a service it fires when the service's injector is destroyed. Its big advantage is that cleanup can live next to the code that needs it — inside a helper function — instead of being split between the constructor and a hook method.

destroy.ts
export const ticks = new Subject<number>();
export const events: string[] = [];

@Component({ selector: 'app-ticker', template: `{{ last() }}` })
export class Ticker implements OnInit, OnDestroy {
  last = signal(0);
  private destroyRef = inject(DestroyRef);

  constructor() {
    // 1. Injection context: no argument needed
    ticks.pipe(takeUntilDestroyed()).subscribe({
      next: (n) => { this.last.set(n); events.push(`ticker got ${n}`); },
      complete: () => events.push('ticker stream completed'),
    });
    this.destroyRef.onDestroy(() => events.push('DestroyRef callback'));
  }

  ngOnInit() {
    // 2. Outside the injection context: pass the DestroyRef explicitly
    ticks.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((n) => events.push(`ngOnInit sub got ${n}`));
  }

  ngOnDestroy() {
    events.push(`ngOnDestroy (destroyed=${this.destroyRef.destroyed})`);
  }
}

@Component({
  selector: 'app-ticker-host',
  imports: [Ticker],
  template: `@if (show()) { <app-ticker /> }`,
})
export class TickerHost {
  show = signal(true);
}
AdvancedDestroy order: ngOnDestroy then DestroyRef
Vitest output
ticker got 1
ngOnInit sub got 1
observers before destroy: true
ngOnDestroy (destroyed=true)
ticker stream completed
DestroyRef callback
destroyed after: true
observers after destroy: false
onDestroy after destroy threw: NG0911: View has already been destroyed.
What’s happening
  1. The test emits 1 on ticks. Both subscriptions receive it, and ticks.observed is true — two subscribers are attached.
  2. show.set(false) removes the @if block, which destroys Ticker. First ngOnDestroy runs. Note that destroyRef.destroyed is already true inside it.
  3. Then the DestroyRef callbacks run in registration order: takeUntilDestroyed() registered its callback first (in the constructor), so the first stream completes (ticker stream completed), then the plain onDestroy callback runs. The ngOnInit subscription's callback was registered last and unsubscribes it silently (it has no complete handler).
  4. After destruction, ticks.observed is false: no subscriber leaked. Emitting 2 reaches nobody.
  5. Registering a new callback on a destroyed DestroyRef throws NG0911: View has already been destroyed. — so check destroyRef.destroyed first if late registration is possible.
  6. onDestroy() also returns an unregister function: const stop = destroyRef.onDestroy(fn); stop(); cancels the callback if you clean up early.
AdvancedDestroy facts for services and DestroyRef

Two facts that come up in interviews:

  • Services only get ngOnDestroy. None of the other hooks are called on services. A test that gives a component-level service both ngOnInit and ngOnDestroy logs only Poller.ngOnDestroy when the component is destroyed. Root services are destroyed when the application is destroyed.
  • DestroyRef works anywhere there's an injection context, which is what makes reusable cleanup possible, e.g. a function useInterval(ms, fn) { const id = setInterval(fn, ms); inject(DestroyRef).onDestroy(() => clearInterval(id)); } called from a component's field initialiser.
AdvancedCleanup Angular handles for you

#takeUntilDestroyed

From my notes: "TakeUntilDestroyed important !!!". takeUntilDestroyed() (from @angular/core/rxjs-interop) is an RxJS operator that completes the stream when the current component, directive or service is destroyed. It replaces a whole block of boilerplate with one line.

The old pattern (Angular 16 and earlier, and still all over techStatus-era code):

old-way.ts
export class OldTicker implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();

  ngOnInit() {
    ticks.pipe(takeUntil(this.destroy$)).subscribe((n) => (this.last = n));
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}
What’s happening
  1. destroy$ is a private Subject that acts as an "off switch".
  2. takeUntil(this.destroy$) passes values through until destroy$ emits, then completes the stream, which unsubscribes from ticks.
  3. ngOnDestroy flips the switch with next() and then complete()s the subject itself.
  4. It works, but it's four pieces in three places for every component, and forgetting ngOnDestroy (or the next()) leaks silently.
AdvancedSubscribing in ngOnInit the wrong way

The modern version is the constructor code in ngOnDestroy and DestroyRef: ticks.pipe(takeUntilDestroyed()).subscribe(…). Called without an argument, it does inject(DestroyRef) itself — so it must run in an injection context (constructor, field initialiser, factory). Calling it from ngOnInit without an argument fails:

destroy.ts (bad)
@Component({ selector: 'app-ticker-bad', template: `` })
export class TickerBad implements OnInit {
  ngOnInit() {
    ticks.pipe(takeUntilDestroyed()).subscribe(); // no injection context here
  }
}
AdvancedNG0203 from takeUntilDestroyed in ngOnInit
Vitest output
NG0203: takeUntilDestroyed() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
What’s happening
  1. ngOnInit is called by Angular's change detection, not by the injector, so there's no active injection context.
  2. takeUntilDestroyed() with no argument tries to inject(DestroyRef), can't, and throws NG0203 with the message above.
  3. The fix used in Ticker.ngOnInit: inject DestroyRef in a field (private destroyRef = inject(DestroyRef)) and pass it: takeUntilDestroyed(this.destroyRef). That form works anywhere, even in a callback long after construction.
AdvancedPitfall: operators after takeUntilDestroyed
AdvancedAlternatives: toSignal and the async pipe

#afterNextRender and afterEveryRender

The render hooks (stable since Angular 20, per @publicApi 20.0 in core.d.ts) answer a different question from the ngAfter* hooks. ngAfterViewInit means "this component's view has been checked". afterNextRender means "the whole application has finished rendering and the DOM is up to date — now you may touch it." afterNextRender runs once; afterEveryRender runs after every render. They are functions you call in an injection context (usually the constructor), not methods you implement, and they never run on the server — which is exactly what you want for DOM measurement, focus, or initialising a non-Angular library like a chart.

To avoid layout thrashing, you can split the work into phases, which run in this fixed order after each render: earlyRead → write → mixedReadWrite → read. Each phase's return value is passed to the next one. A plain callback (no phase object) runs in mixedReadWrite, which the docs advise against.

render-hooks.ts
@Component({
  selector: 'app-meter',
  template: `<div #bar class="bar">{{ value() }}%</div>`,
})
export class Meter implements AfterViewInit {
  value = signal(40);
  bar = viewChild.required<ElementRef<HTMLDivElement>>('bar');

  constructor() {
    afterNextRender({
      earlyRead: () => {
        phases.push('earlyRead');
        return this.bar().nativeElement.textContent; // value handed to the next phase
      },
      write: (text) => {
        phases.push(`write (got "${text}")`);
        this.bar().nativeElement.style.width = `${this.value()}%`;
        return 'written';
      },
      read: (fromWrite) => {
        phases.push(`read (got "${fromWrite}") width=${this.bar().nativeElement.style.width}`);
      },
    });

    afterEveryRender({
      read: () => phases.push(`afterEveryRender read: ${this.bar().nativeElement.textContent}`),
    });
  }

  ngAfterViewInit() {
    phases.push('ngAfterViewInit');
  }
}
AdvancedRender phase order in a spec
Vitest output
ngAfterViewInit
earlyRead
write (got "40%")
read (got "written") width=40%
afterEveryRender read: 40%
--- value.set(75)
afterEveryRender read: 75%
What’s happening
  1. The constructor only registers callbacks; nothing runs yet. Registration needs the injection context, and the callbacks are cleaned up with the component's DestroyRef automatically (unless you pass manualCleanup: true).
  2. The first change detection checks the view, and ngAfterViewInit runs as part of it. That's still inside change detection.
  3. When the whole render is done, the phases run in order. earlyRead reads the rendered text "40%" and returns it; write receives it as its parameter, sets the width (a DOM write) and returns 'written'; read receives that and reads the width back. Ordering reads and writes like this avoids forcing the browser to recalculate layout between them.
  4. The afterEveryRender read callback also runs after that first render (registered second, same read phase → after the afterNextRender read).
  5. value.set(75) causes another render. afterNextRender has already fired once and is gone; afterEveryRender runs again and sees 75%.
  6. In the canonical log, afterEveryRender also ran for Projected after name.set('Grace'), although Projected's view wasn't re-rendered. These callbacks are about the application rendering, not about your component changing. The order between components there (children first) comes from Angular queuing a component's callbacks when its view is first refreshed; don't rely on it.
AdvancedafterNextRender vs ngAfterViewInit table

From my notes, "Difference between afterNextRender and ngAfterViewInit":

ngAfterViewInitafterNextRender
What finishedthis component's view and its childrenthe whole application's render
Runsinside change detectionafter change detection, before the next one
Server (SSR)yesno — browser only
Howa class methoda function called in an injection context (also usable in services and directives)
Repeating versionngAfterViewChecked (when the declaring view is checked)afterEveryRender (after every app render)
PhasesnoearlyRead / write / mixedReadWrite / read
AdvancedCombining effects and render phases

#Lifecycle interview traps

From my notes: "Important interview traps." Each one below was checked in the Angular 22 sandbox.

  • "ngOnChanges always runs first." Only if the component has at least one bound input. A component with no inputs (like Parent in the canonical log) goes straight from constructor to ngOnInit.
  • "ngOnInit runs after the component is rendered." No — it runs before its own view is checked. Child components aren't initialised and @if blocks don't exist yet.
  • "ngOnChanges only works with @Input()." In Angular 22 it also receives signal input() changes. It still ignores mutations and assignments from code.
  • "The parent's ngAfterViewInit runs first." It runs last — after every child's view hooks.
  • "Projected content is part of the child." Its hooks are run by the parent's view, and the parent creates it even if the child never shows it.
  • "OnPush children don't get ngDoCheck." They do, every time the parent's view is checked — even when their own view is skipped.
  • "My component's ngDoCheck/ngAfterViewChecked runs when its signals change." Not in zoneless Angular: only the component's own view is refreshed, and its hooks belong to its parent's view.
  • "Services have lifecycle hooks." Only ngOnDestroy.
  • "No NG0100 means no bug." With OnPush (the Angular 22 default), the dev-mode check can't see the bug; you get a stale screen instead. Use provideCheckNoChangesConfig({ exhaustive: true }) in tests.
  • "takeUntilDestroyed() works anywhere." Without an argument it needs an injection context (NG0203), and it must be the last operator.
When does each hook see the input?

app-profile has userId = input.required<number>() and theme = input('light'), and the parent writes <app-profile [userId]="42" theme="dark" />. What does this.theme() return in the constructor, in ngOnChanges and in ngOnInit? What happens if the constructor calls this.userId()?

Show answer

Constructor: 'light'. ngOnChanges and ngOnInit: 'dark'. Reading userId() in the constructor is a compile error.

What’s happening
  1. The constructor runs during the parent's creation pass, before bindings are evaluated, so theme() returns its default 'light'.
  2. During the first check of the parent's view, Angular sets both inputs, then calls ngOnChanges with both entries (firstChange: true), then ngOnInit. Both see 'dark' and 42.
  3. this.userId() in the constructor is rejected by the compiler with NG8118 ("is a required input and does not have a value in this context"). If you sneak it past the compiler it throws NG0950 at runtime.
Which hooks run?

A component's template shows {{ count() }}, and a setInterval in it calls count.update(c => c + 1). It implements ngDoCheck, ngAfterViewChecked and registers afterEveryRender. In a zoneless Angular 22 app, which of the three run on each tick?

Show answer

Only afterEveryRender.

What’s happening
  1. count.update is a signal write. It marks just this component's view for refresh (its template read count()), so Angular schedules a render and refreshes only that view.
  2. ngDoCheck and ngAfterViewChecked belong to the parent's view, which isn't checked, so they don't run (the chat test above shows exactly this for ngAfterViewChecked).
  3. afterEveryRender runs after every application render, whatever caused it, so it runs on every tick.
  4. If the interval instead set a plain field (this.count++), nothing would be scheduled at all in a zoneless app — the screen wouldn't update. That's the mutation problem in zoneless form.

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.