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:
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.
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.
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.
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 requiredsignal input in the constructor is so clearly wrong that Angular 22 rejects it at compile time:
input.required<number>() declares an input that must be bound by the parent; it has no default value.
The constructor runs before any binding is evaluated, so there is nothing to read yet.
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.
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.
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:
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
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.
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.
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.
ngOnInitdoes 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.
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:
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
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.
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.
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.)
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'.
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
constructor
ngOnInit
Called by
JavaScript (new), via Angular's DI factory
Angular, during the first check of the parent's view
Inputs
defaults only; required inputs throw
set to the parent's values
Injection context
yes — inject(), DestroyRef, takeUntilDestroyed() work
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 beforengOnInit 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: ngOnChangesis 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.
fixture.componentInstance.amount.set('25');await fixture.whenStable();
fixture.componentInstance.currency.set('USD');// same valueawait fixture.whenStable();
fixture.componentInstance.currency.set('EUR');
fixture.componentInstance.amount.set('30');await fixture.whenStable();
price.currency ='GBP';// assigned from code, not through a bindingawait fixture.whenStable();
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 number10: the parent passes the string '10' (interpolation always produces a string), the numberAttribute transform converts it, and SimpleChange carries the transformed value.
amount.set('25'): only amount changed, so changes contains only amount: 10→25.
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.
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.
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.
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).
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
exportconst log:string[]=[];@Directive()exportabstractclassLoggedimplementsOnChanges, OnInit, DoCheck, AfterContentInit,
AfterContentChecked, AfterViewInit, AfterViewChecked, OnDestroy {protectedabstract label:string;// Called from each subclass constructor, after its `label` field is set.protectedtrack(){
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>`})exportclassProjectedextendsLogged{protected label ='Projected';constructor(){super();this.track();}}@Component({
selector:'app-child',
template:`<p>{{ name() }} / {{ legacy }}</p><ng-content />`,})exportclassChildextendsLogged{
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>
`,})exportclassParentextendsLogged{
name =signal('Ada');
legacy =signal('v1');protected label ='Parent';constructor(){super();this.track();}}
What’s happening
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.
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.
Child has one signal input (name) and one decorator input (legacy), so we can compare how each one reaches ngOnChanges.
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.
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:
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
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.
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.
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).
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.
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.
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.
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:
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.
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.
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.
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.
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.
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?
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.
Top's view is refreshed. Both Kids are declared there, so both get ngDoCheck — including the second Kid, which wasn't clicked.
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".
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.
Then the host view finishes with Top: ngAfterViewChecked, and finally the render hooks run for everyone.
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:
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 staleexpect(dc.doCheckCalls).toBe(2);// initial render + the click
What’s happening
Initially both children receive the same one-element array and render 1 tags. TagCountDoCheck.ngDoCheck ran once (first render) and stored lastLength = 1.
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.
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).
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.
TagCount has no such check, so Angular skips its OnPush view and it keeps showing 1 tags — a stale UI with no error.
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.
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> }
`,})exportclassPanelimplementsOnInit, 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>
`,})exportclassPanelHost{}
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.
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.
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.
ngAfterViewInit: the panel's view has been checked, the @if block exists, and every query sees everything.
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 logProjected'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() 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>
`,})exportclassChatimplementsAfterViewChecked{
messages =signal(['hi']);
list = viewChild.required<ElementRef<HTMLUListElement>>('list');
viewChecked:number[]=[];// <li> count seen by ngAfterViewChecked
renderEffect:number[]=[];// <li> count seen by afterRenderEffectconstructor(){// Modern: re-runs after rendering whenever a signal it read has changedafterRenderEffect({write:()=>{this.messages();// dependencyconst 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 />`})exportclassChatPage{}
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 runexpect(chat.renderEffect).toEqual([1,2]);
fixture.nativeElement.querySelector('button').click();// DOM event inside the chatawait fixture.whenStable();expect(chat.viewChecked).toEqual([1,3]);expect(chat.renderEffect).toEqual([1,2,3]);
What’s happening
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).
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.
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.
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.
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).
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>`,})exportclassStatusEagerimplementsAfterViewInit{
status ='loading';ngAfterViewInit(){this.status ='ready';}}@Component({
selector:'app-status-plain',// default strategy in Angular 22: OnPush
template:`<p>{{ status }}</p>`,})exportclassStatusPlainimplementsAfterViewInit{
status ='loading';ngAfterViewInit(){this.status ='ready';// the <p> was already checked with 'loading'}}@Component({
selector:'app-status-signal',
template:`<p>{{ status() }}</p>`,})exportclassStatusSignalimplementsAfterViewInit{
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
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'.
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().
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".
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.
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() 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
exportconst ticks =newSubject<number>();exportconst events:string[]=[];@Component({ selector:'app-ticker', template:`{{ last() }}`})exportclassTickerimplementsOnInit, 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 /> }`,})exportclassTickerHost{
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
The test emits 1 on ticks. Both subscriptions receive it, and ticks.observed is true — two subscribers are attached.
show.set(false) removes the @if block, which destroys Ticker. First ngOnDestroy runs. Note that destroyRef.destroyed is already true inside it.
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).
After destruction, ticks.observed is false: no subscriber leaked. Emitting 2 reaches nobody.
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.
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.
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):
destroy$ is a private Subject that acts as an "off switch".
takeUntil(this.destroy$) passes values through until destroy$ emits, then completes the stream, which unsubscribes from ticks.
ngOnDestroy flips the switch with next() and then complete()s the subject itself.
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:``})exportclassTickerBadimplementsOnInit{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
ngOnInit is called by Angular's change detection, not by the injector, so there's no active injection context.
takeUntilDestroyed() with no argument tries to inject(DestroyRef), can't, and throws NG0203 with the message above.
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
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>`,})exportclassMeterimplementsAfterViewInit{
value =signal(40);
bar = viewChild.required<ElementRef<HTMLDivElement>>('bar');constructor(){afterNextRender({earlyRead:()=>{
phases.push('earlyRead');returnthis.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');}}
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).
The first change detection checks the view, and ngAfterViewInit runs as part of it. That's still inside change detection.
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.
The afterEveryRenderread callback also runs after that first render (registered second, same read phase → after the afterNextRender read).
value.set(75) causes another render. afterNextRender has already fired once and is gone; afterEveryRender runs again and sees 75%.
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":
ngAfterViewInit
afterNextRender
What finished
this component's view and its children
the whole application's render
Runs
inside change detection
after change detection, before the next one
Server (SSR)
yes
no — browser only
How
a class method
a function called in an injection context (also usable in services and directives)
Repeating version
ngAfterViewChecked (when the declaring view is checked)
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
The constructor runs during the parent's creation pass, before bindings are evaluated, so theme() returns its default 'light'.
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.
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
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.
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).
afterEveryRender runs after every application render, whatever caused it, so it runs on every tick.
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.