Angular Intermediate #4: Component Lifecycle Hooks
Last time we covered RxJS, and at the end mentioned in passing that “subscriptions need to be cleaned up at some point.” When exactly that “some point” is, and what other moments you can step into during a component’s life — that’s what we cover in earnest this time.
An Angular component doesn’t simply appear on the screen and then vanish. It goes through a sequence — born, receiving input, rendered, receiving input again, cleaned up, and gone. At each step, Angular calls a method you’ve defined, and those calls are lifecycle hooks.
The big picture of the lifecycle #
Let’s lay out the flow on a single page.
constructor() // class is created (DI happens)
▼
ngOnChanges() // initial input values arrive (and on every change after)
▼
ngOnInit() // initialization. inputs are usable
▼
ngDoCheck() // runs on every change-detection pass (often)
▼
ngAfterContentInit() // children projected via <ng-content> are ready
▼
ngAfterViewInit() // own template is rendered. ViewChild is usable
▼ (ngOnChanges and below repeat on each change-detection cycle)
▼
ngOnDestroy() // component removed. cleanup happens hereLooks crowded, but the ones you actually use in real code are few. Master ngOnInit, ngOnDestroy, ngOnChanges, ngAfterViewInit and you’ve covered 90%.
ngOnInit — the most-used hook #
The first and most frequent hook you’ll meet is ngOnInit. It’s the place for the component’s initialization logic.
import { Component, Input, OnInit } from '@angular/core';
@Component({
selector: 'app-article',
standalone: true,
template: `<h1>{{ title }}</h1>`,
})
export class ArticleComponent implements OnInit {
@Input() articleId!: string;
title = '';
constructor() {
// articleId is still undefined!
console.log('constructor:', this.articleId);
}
ngOnInit() {
// here, articleId has a value
this.loadArticle(this.articleId);
}
private loadArticle(id: string) { /* ... */ }
}A common question — “can’t I just do that in the constructor?” Their roles are different.
- constructor — the place to instantiate the class. Use it only for dependency injection (DI). At this point, values from
@Input()(orinput()) haven’t been set yet. - ngOnInit — called once right after all input values have been set. Initialization that uses inputs goes here.
The rule is simple: only inject() in the constructor; initialization that uses inputs belongs in ngOnInit.
input() signal in Angular 17+ is itself a signal, so it can be read naturally from anywhere in the component, which lightens the load on ngOnInit. That said, “async initialization that runs once” still naturally lives in ngOnInit.ngOnDestroy — the cleanup step #
Called right before the component is removed from the tree. The place to cut external connections.
export class ClockComponent implements OnInit, OnDestroy {
time = '';
private intervalId?: number;
ngOnInit() {
this.intervalId = window.setInterval(() => {
this.time = new Date().toLocaleTimeString();
}, 1000);
}
ngOnDestroy() {
if (this.intervalId !== undefined) clearInterval(this.intervalId);
}
}If you don’t clean up setInterval, the callback keeps firing after the component is gone, leaking memory. RxJS subscriptions, WebSocket connections, global event listeners — anything wired to the outside world should be released here.
That said, in modern Angular you write ngOnDestroy directly less and less. Cleaner tools have arrived.
DestroyRef and takeUntilDestroyed — the modern cleanup pattern #
DestroyRef, introduced in Angular 16, lets you register a callback to run when this component is destroyed. Combined with the RxJS operator takeUntilDestroyed, subscription cleanup becomes a one-liner.
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({
selector: 'app-search',
standalone: true,
template: `<p>tick: {{ count }}</p>`,
})
export class SearchComponent implements OnInit {
private destroyRef = inject(DestroyRef);
count = 0;
ngOnInit() {
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(n => (this.count = n));
}
}takeUntilDestroyed automatically tears down the subscription the moment the component is destroyed. No OnDestroy interface, no extra Subject.
DestroyRef works for non-RxJS cleanup too. A single destroyRef.onDestroy(callback) registers cleanup logic, so you don’t even have to implement the OnDestroy interface.
ngOnInit() {
const onScroll = () => (this.scrollY = window.scrollY);
window.addEventListener('scroll', onScroll);
this.destroyRef.onDestroy(() => {
window.removeEventListener('scroll', onScroll);
});
}For new code, reach for this pattern first.
ngOnChanges — every time inputs change #
Called whenever a value bound to @Input() (or the new input()) changes. You receive a SimpleChanges object describing which inputs changed and to what.
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
@Component({
selector: 'app-chart',
standalone: true,
template: `<canvas #c></canvas>`,
})
export class ChartComponent implements OnChanges {
@Input() data: number[] = [];
@Input() color = 'blue';
ngOnChanges(changes: SimpleChanges) {
if (changes['data']) {
console.log('previous:', changes['data'].previousValue);
console.log('current:', changes['data'].currentValue);
this.redraw();
}
// skip redraw when only color changes
}
private redraw() { /* ... */ }
}ngOnChanges is also called once right before ngOnInit (the first input set). After that, it runs every time inputs change.
If you’re using input() signals, reaching for effect() or computed() to react to input changes is more natural than ngOnChanges (covered below).
ngAfterViewInit — when DOM access becomes possible #
ngOnInit lets you use inputs, but the component’s own template hasn’t been rendered to the DOM yet. A child component or DOM element pointed to by ViewChild may still be undefined at that moment. ngAfterViewInit runs after the component’s own template has been rendered.
@Component({
selector: 'app-auto-focus-input',
standalone: true,
template: `<input #box type="text" />`,
})
export class AutoFocusInputComponent implements AfterViewInit {
@ViewChild('box') box!: ElementRef<HTMLInputElement>;
ngAfterViewInit() {
this.box.nativeElement.focus();
}
}Trying the same in ngOnInit would give you an undefined error. When you need to touch the DOM directly or call methods on a child component, ngAfterViewInit is the right place.
viewChild() signal function was added and replaces the @ViewChild decorator. Because it’s a signal, you can react to changes with effect() instead of ngAfterViewInit. Decorator-style code will be around for a while though, so it’s worth knowing both.The remaining hooks — briefly #
You’ll rarely meet these in practice, but it’s worth knowing the names.
ngDoCheck— runs on every change-detection cycle. Calls are frequent, so heavy logic is forbidden. Use it only when you need to detect changes Angular doesn’t catch automatically (e.g., in-place array mutation).ngAfterContentInit/ngAfterContentChecked— right after content projected via<ng-content>is ready/checked. You’ll occasionally see them when working withContentChildqueries.
99% of components never touch these three, so just be aware they exist.
The lifecycle in the Signals era — effect() #
Signals, introduced in Angular 16, are changing how the lifecycle is handled. The center of it is effect().
export class CounterComponent {
count = signal(0);
constructor() {
effect(() => {
console.log('count changed:', this.count());
document.title = `Count: ${this.count()}`;
});
}
}effect() automatically re-runs whenever any signal it reads changes. The flow of “run once in ngOnInit, re-run in ngOnChanges, clean up in ngOnDestroy” collapses into one line.
effect() also cleans itself up automatically when the component (or the registered injection context) is destroyed. You don’t even need to manage a DestroyRef.
Combined with input() signals, it can mostly replace ngOnChanges too.
@Component({ /* ... */ })
export class ChartSignalComponent {
data = input<number[]>([]);
color = input<string>('blue');
constructor() {
effect(() => {
// runs automatically whenever data or color changes
this.redraw(this.data(), this.color());
});
}
private redraw(data: number[], color: string) { /* ... */ }
}For new code, leaning on signals + effect() is the trend. But that doesn’t mean you have to rip out every existing ngOnInit/ngOnDestroy. Just shift gradually as you write new pieces.
Common mistakes #
A few traps that people fall into around lifecycle hooks.
1. ngOnInit doesn’t await async results #
Marking ngOnInit as async doesn’t make Angular wait. The next lifecycle phase runs before the await resolves. To defer rendering until data is ready, use a signal or @if to check “has the data arrived yet?”
@if (user(); as u) {
<p>{{ u.name }}</p>
} @else {
<p>Loading...</p>
}2. Accessing ViewChild in ngOnInit #
At ngOnInit, the component’s own template hasn’t been rendered yet, so ViewChild may be undefined. DOM/child component access goes in ngAfterViewInit (or signal-based viewChild() + effect()).
3. OnPush mode and lifecycle call frequency #
When you mark a component with ChangeDetectionStrategy.OnPush, change detection runs far less frequently, and calls to hooks like ngDoCheck/ngAfterContentChecked drop accordingly. It’s safer to avoid placing business logic that depends on these calls.
4. Doing heavy work in the constructor #
constructor is for DI, not initialization logic. Move side effects like HTTP calls and timer setup to ngOnInit or effect().
Wrapping up #
In this post we walked through a component’s lifecycle from start to finish and the hooks you can step into. To summarize:
- Lifecycle flow: create → input → init → render → (repeat) → destroy
- The hooks you’ll use most:
ngOnInit,ngOnDestroy,ngOnChanges,ngAfterViewInit - Constructor is for DI only; initialization that uses inputs belongs in
ngOnInit - Cleanup is cleaner with
DestroyRef+takeUntilDestroyedthan withngOnDestroy - DOM/
ViewChildaccess starts atngAfterViewInit - In the Signals era,
effect()replaces a large chunk of the lifecycle
In the next post, “Angular Intermediate #5 Standalone and Lazy Loading,” we take a deeper look at the structure of standalone components that work without NgModule, and the lazy-loading pattern that splits code per route to keep initial loads fast.