Component Lifecycle
Five hooks worth knowing, what each is for, and the order — which is the part people get wrong.
The lifecycle hooks and their order
| Angular | React |
|---|---|
ngOnInit | useEffect(fn, []) |
ngOnChanges | useEffect(fn, [prop]) |
ngOnDestroy | the cleanup function returned from useEffect |
You'll use ngOnInit, ngOnChanges, ngAfterViewInit and ngOnDestroy. Don't recite all eight — name these four and say what each is for.
ngOnInit vs the constructor
Constructor = dependency injection only. ngOnInit = everything else, because that's when the inputs exist.
export class OrderDetailComponent implements OnInit {
@Input() orderId!: number;
order?: Order;
constructor(private api: OrderService) {
console.log(this.orderId); // undefined — inputs aren't set yet
}
ngOnInit() {
console.log(this.orderId); // 42 — now it's here
this.api.get(this.orderId).subscribe(o => this.order = o);
}
}
| Constructor | Inject services. Nothing else. |
| ngOnInit | Read inputs, load data, set up subscriptions and forms. |
The constructor runs when the class is created, before Angular has set the @Input values, so
anything that depends on an input has to go in ngOnInit. I keep the constructor to dependency
injection only — it also makes the component easier to test, because constructing it doesn't
kick off an API call.
ngOnChanges
export class OrderDetailComponent implements OnInit, OnChanges {
@Input() orderId!: number;
ngOnChanges(changes: SimpleChanges) {
console.log('changes', changes['orderId']?.currentValue);
}
ngOnInit() {
console.log('init', this.orderId);
this.load(this.orderId);
}
}
The fix: put the reload in ngOnChanges,
not ngOnInit — or if the id comes from the URL, subscribe to
paramMap instead (Q29). This is one of the most common Angular bugs.
ngOnChanges(changes: SimpleChanges) {
if (changes['orderId'] && !changes['orderId'].firstChange) {
this.load(this.orderId);
}
}
SimpleChanges gives you previousValue, currentValue and
firstChange for each changed input.
Mutating an object you passed in (this.user.name = 'x') does
not trigger ngOnChanges — the reference is the same. Replace the
object instead. This matters even more with OnPush (Q46).
Runs once, after the component's template and its child components have
rendered. It's the only place a @ViewChild is reliably available.
@ViewChild('search') searchInput!: ElementRef;
ngOnInit() { this.searchInput; } // undefined — the view doesn't exist yet
ngAfterViewInit() { this.searchInput.nativeElement.focus(); } // works
Use it for: focusing an input, initialising a chart or map library, measuring an element.
Changing a value that's bound in the template from inside ngAfterViewInit throws
this in development mode — Angular has already checked that value this cycle. Wrap it in
setTimeout(), or move the work to ngOnInit.
ngOnDestroy
The last hook before the component is removed — and the only place to clean up what you started.
export class LiveComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private timer?: any;
ngOnInit() {
this.feed.updates$
.pipe(takeUntil(this.destroy$))
.subscribe(u => this.rows = u);
this.timer = setInterval(() => this.refresh(), 30000);
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
clearInterval(this.timer);
}
}
| Clean up | Or else |
|---|---|
| Subscriptions | They keep running and hold the component in memory |
setInterval / setTimeout | The timer fires after the user has navigated away |
| Manual DOM listeners | Same — plus they stack up each time the component is created |
Anything using the async pipe needs none of this — it unsubscribes itself. That's the main reason to prefer it (Q44).
ngOnDestroy is where I unsubscribe and clear timers. A subscription that outlives its
component keeps the component in memory and keeps running — navigate in and out of that screen
twenty times and you have twenty live subscriptions. I use takeUntil with a destroy subject, or
better, the async pipe so there's nothing to clean up.