Angular Component Lifecycle Hooks in 2026: Every Hook, effect(), and afterNextRender [2026]
Angular components go through a predictable sequence of moments — created, inputs received, view rendered, content projected, destroyed. The framework gives you nine hooks to react to those moments, but in 2026 a signal-first codebase reaches for only two or three of them. This lesson explains every hook, the exact order they fire, and which ones the modern reactive primitives (effect(), afterNextRender()) have effectively retired.
This is lesson 2.7 of the Angular Tutorial. It assumes you have completed lesson 2.1 (component anatomy) and lesson 2.4 (input signals) — both are referenced below.
The full hook list, in firing order #
Every Angular component goes through these phases. The hook (if you implement it) runs at each step:
| Order | Hook | When it fires | Modern alternative |
|---|---|---|---|
| 1 | constructor |
Class instantiated, DI injected | Still essential — use inject() here |
| 2 | ngOnChanges |
Inputs change (also on first set) | Signals fire reactively; rarely needed |
| 3 | ngOnInit |
Once, after first ngOnChanges |
constructor works in most cases |
| 4 | ngDoCheck |
Every CD cycle, before content check | Avoid — use signals |
| 5 | ngAfterContentInit |
Once, after content projection ready | contentChild() signal queries |
| 6 | ngAfterContentChecked |
Every CD cycle, after content check | Avoid |
| 7 | ngAfterViewInit |
Once, after view (template) rendered | afterNextRender() or viewChild() |
| 8 | ngAfterViewChecked |
Every CD cycle, after view check | afterRender() if needed |
| 9 | ngOnDestroy |
Component about to be destroyed | DestroyRef.onDestroy() or effect() cleanup |
Learning the order matters once. Day to day, you only need the constructor, effect(), afterNextRender(), and DestroyRef.
What modern Angular actually uses #
The four modern primitives that replace the nine legacy hooks:
inject() in the constructor #
Dependency injection runs in the constructor. Field initializers (x = inject(Y)) effectively run there too.
export class CartComponent {
private auth = inject(AuthService);
private http = inject(HttpClient);
// No need for ngOnInit — initialization happens at construction
}
effect() for side effects #
effect() from @angular/core runs whenever any signal it reads changes. It replaces ngOnChanges for the vast majority of cases:
import { Component, effect, input } from '@angular/core';
export class UserStatsComponent {
user = input.required<User>();
constructor() {
// Replaces ngOnChanges + manual change tracking
effect(() => {
console.log(`User changed to ${this.user().name}`);
analytics.track('user_viewed', { id: this.user().id });
});
}
}
Key behaviors of effect():
- Runs once on creation, then again whenever a tracked signal changes
- Tracks dependencies automatically — any signal read inside the function becomes a dependency
- Cleanup function: return a function from inside
effect(), it runs before the next execution and on destroy - Must be called in an injection context (constructor or field initializer)
effect((onCleanup) => {
const handle = setInterval(() => console.log(this.user().name), 1000);
onCleanup(() => clearInterval(handle));
});
afterNextRender() for DOM measurements #
When you need to read computed DOM properties (heights, scroll positions, element bounding rectangles), the timing matters. The view must be in the DOM and laid out. afterNextRender() is the modern primitive for this:
import { afterNextRender, ElementRef, inject } from '@angular/core';
export class StickyHeaderComponent {
private host = inject(ElementRef<HTMLElement>);
constructor() {
afterNextRender(() => {
const height = this.host.nativeElement.offsetHeight;
document.documentElement.style.setProperty('--header-h', `${height}px`);
});
}
}
Replaces ngAfterViewInit for any case where you need access to the rendered DOM.
DestroyRef.onDestroy() for cleanup #
DestroyRef is the modern, injectable cleanup hook:
import { DestroyRef, inject } from '@angular/core';
import { fromEvent } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class ResizeWatcherComponent {
private destroyRef = inject(DestroyRef);
constructor() {
fromEvent(window, 'resize')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => console.log('resized'));
// Or use the raw cleanup callback
this.destroyRef.onDestroy(() => console.log('component dying'));
}
}
No more OnDestroy interface, no more manual unsubscribe(). The takeUntilDestroyed() operator handles RxJS cleanup; onDestroy() handles everything else.
Comparing legacy vs modern #
| Want | Legacy way | Modern way |
|---|---|---|
| Run code once at startup | ngOnInit |
constructor (or effect() if it reads signals) |
| React to input changes | ngOnChanges |
effect() reading input signals |
| Read DOM after render | ngAfterViewInit |
afterNextRender() |
| Cleanup on destroy | ngOnDestroy + manual unsubscribe |
DestroyRef.onDestroy() + takeUntilDestroyed() |
| Reference child component | @ViewChild() + ngAfterViewInit |
viewChild() signal (lesson 2.13) |
| Reference projected content | @ContentChild() + ngAfterContentInit |
contentChild() signal (lesson 2.13) |
The modern pattern is shorter, type-safer, and reactive. Use it for new code.
When you DO still need a legacy hook #
A handful of cases still warrant the old hooks:
ngOnChangeswithSimpleChanges— when you need the previous value of an input, not just the current one. Signals only expose the current value. (You can also store previous in acomputedchain, but it's less ergonomic.)ngDoCheck— extremely rare. The custom CD path for non-signal libraries that mutate their data in place.ngAfterViewChecked— rare. Reading layout after every CD cycle (most cases wantafterRender()instead).
If you find yourself reaching for these, double-check whether a signal-based approach would work. Nine times out of ten, it does.
The full firing order, visualized #
For a child component being created and inserted under a parent:
1. Parent's CD tick begins
2. New child component created
3. Child constructor runs (DI, field initializers)
4. Inputs set (any `input()` signals now have parent's values)
5. Child's ngOnChanges runs (legacy) with the initial values
6. Child's ngOnInit runs (legacy)
7. Child's ngDoCheck runs (legacy)
8. Child's content children projected
9. Child's ngAfterContentInit + ngAfterContentChecked run (legacy)
10. Child's view template renders
11. Child's ngAfterViewInit + ngAfterViewChecked run (legacy)
12. Child's afterNextRender callbacks run (modern)
13. Child's effect() callbacks fire for any signals read during the above
On subsequent CD cycles, steps 5, 7, 9, 11 may re-fire; steps 12 (afterNextRender) only on render; effects fire whenever their tracked signals change.
On destroy:
1. Component removed from DOM
2. effect() cleanup functions run (in reverse-registration order)
3. DestroyRef.onDestroy() callbacks run (in reverse-registration order)
4. ngOnDestroy runs (legacy)
5. Component reference released for GC
A realistic component using only modern primitives #
import { Component, effect, input, inject, DestroyRef, afterNextRender, ElementRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-clock',
template: `<time>{{ now() }}</time>`,
})
export class ClockComponent {
format = input<'short' | 'long'>('short');
private host = inject(ElementRef<HTMLTimeElement>);
private destroyRef = inject(DestroyRef);
protected now = signal(new Date().toLocaleString());
constructor() {
// Update every second; auto-cleanup on destroy
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.now.set(this.formatNow()));
// React to format input changes
effect(() => {
console.log(`Format changed to ${this.format()}`);
this.now.set(this.formatNow());
});
// Run once after first render — measure DOM
afterNextRender(() => {
console.log(`Clock width: ${this.host.nativeElement.offsetWidth}px`);
});
}
private formatNow() {
return this.format() === 'short'
? new Date().toLocaleTimeString()
: new Date().toLocaleString();
}
}
Not an ngOnInit, ngOnDestroy, or ngOnChanges in sight. All initialization happens in the constructor; all reactivity goes through effect(); all cleanup is automatic.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
effect() must be called within an injection context |
Called effect() outside the constructor/field initializer |
Move it into the constructor, or capture an Injector and wrap with runInInjectionContext |
afterNextRender runs but DOM not yet ready |
You forgot it runs once, not on every render | Use afterRender() if you need it every cycle (rare) |
| Memory leak from leftover subscription | Forgot takeUntilDestroyed() |
Always pipe RxJS subscriptions through takeUntilDestroyed(destroyRef) |
ngOnChanges not firing on input() signals |
input() doesn't trigger ngOnChanges — it's a signal |
Use effect() instead, or migrate the input back to @Input() (not recommended) |
| Constructor runs in tests but DOM isn't ready | Tests bypass the render cycle | TestBed.tick() to advance to first render, then read DOM |
What's next #
Lesson 2.8 covers pipes — built-in pipes plus custom pipes you write yourself. Lessons 2.9–2.14 cover directives, ng-template, styling, host metadata, signal queries, and programmatic component creation — completing the Components & Templates module.
After Module 2, Module 3 dives into the signal system in depth. The effect() you saw in this lesson gets its own dedicated lesson there, with timing rules, glitch-free guarantees, and untracked().
Try it yourself #
Replace ngOnInit + ngOnDestroy + a manual subscription with the modern primitives:
// Legacy — pre-v17 patterns
export class WebSocketCardComponent implements OnInit, OnDestroy {
private ws?: WebSocket;
ngOnInit() { this.ws = new WebSocket('wss://example.com'); }
ngOnDestroy() { this.ws?.close(); }
}
// Modern — 2026 patterns
export class WebSocketCardComponent {
constructor() {
const ws = new WebSocket('wss://example.com');
inject(DestroyRef).onDestroy(() => ws.close());
}
}
The modern version has no interfaces to implement, no separate methods, no field that's ? because it might not be set yet. The whole lifecycle is one constructor.
get_best_practicesFor input() signals specifically — no, the constructor works. Inputs are signals, so reading this.myInput() in effect() (set up in the constructor) gives you the current value AND re-runs on every change. The constructor body runs before inputs are set, but anything inside effect() or computed() reads them lazily — at the right time. ngOnInit is only necessary if you’re still using @Input() decorators.Lesson 2.8 picks up pipes — the cleanest way to transform values for display.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


