Angular Lifecycle Hooks: ngOnInit, ngOnDestroy, effect, and Cleanup Patterns
Components are born, live through countless updates, and die when navigation or an @if removes them. Angular exposes hooks into that lifecycle โ and misunderstanding them produces two beloved bug genres: work done too early (โwhy is my input undefined in the constructor?โ) and cleanup never done (the listener and subscription leaks that slowly strangle long-running apps).
The signal era simplified this story dramatically โ several hooks youโll see all over legacy code are now niche. This guide covers the lifecycle as it actually is: the two hooks that matter, the reactive primitives replacing the rest, the cleanup discipline, and a translation table for the old ones.
The Life of a Component
constructor class instantiated; inject() dependencies here โ (inputs NOT set yet โ the classic trap) โผ ngOnInit inputs have values; component "starts" โผ (living) signals update, template re-renders as state changes โ effects run when their dependencies change โผ ngOnDestroy removed from DOM โ cleanup momentThree phases worth distinct mental slots: construction (wiring), initialization (inputs available โ startup logic), destruction (cleanup). The dozens of intermediate hooks legacy Angular offered (ngDoCheck, ngAfterViewCheckedโฆ) existed to observe change-detection churn โ signals made most of them unnecessary, because reactivity now tracks itself.
Constructor vs ngOnInit: the eternal interview question
export class OrderDetail { private api = inject(OrderApi); // constructor territory: wiring orderId = input.required<string>();
constructor() { // this.orderId() here โ throws! inputs aren't set during construction }
ngOnInit() { // inputs are live โ safe to use for startup logic this.load(this.orderId()); }}The rule: constructors wire (inject, initialize fields); ngOnInit starts (anything needing inputs). The reason is mechanical โ Angular constructs the instance first, then binds inputs, then calls ngOnInit. Reading an input during construction reads nothing.
The signal-era refinement: much of what ngOnInit did is better expressed reactively. โLoad when the id input is available and reload when it changesโ was historically ngOnInit + ngOnChanges; now itโs one declaration:
export class OrderDetail { orderId = input.required<string>(); order = resource({ params: () => ({ id: this.orderId() }), loader: ({ params }) => this.api.getOrder(params.id), }); // or, pre-resource: an effect / rxResource watching orderId()}The derivation subsumes both hooks โ initial load and change-reaction are one reactive statement. Modern components often have no ngOnInit at all; its remaining legitimate role is genuinely one-time imperative startup that doesnโt fit a derivation.
ngOnDestroy and the Cleanup Discipline
Everything a component starts, it must stop. The destruction hook is where leaks go to be prevented:
export class LiveTicker implements OnDestroy { private ws = new WebSocket('wss://ticker.example.com'); private timerId = setInterval(() => this.poll(), 30_000);
ngOnDestroy() { this.ws.close(); clearInterval(this.timerId); }}The leak inventory โ what must be cleaned: intervals/timeouts, WebSockets and event sources, document/window-level listeners (host listeners clean themselves; global ones donโt), long-lived RxJS subscriptions, and registrations with external libraries. The failure mode is silent: destroyed componentsโ callbacks keep firing, closures keep their captured component alive, memory climbs, and โthe app gets slow after an hour of use.โ
The modern ergonomics improve on the hook itself โ DestroyRef lets any code register cleanup without implementing the interface, composably:
export class LiveTicker { private destroyRef = inject(DestroyRef);
constructor() { const id = setInterval(() => this.poll(), 30_000); this.destroyRef.onDestroy(() => clearInterval(id)); // cleanup declared AT the setup }}Setup and teardown adjacent โ the try-with-resources instinct for components. This pattern is also what powers takeUntilDestroyed() for observables (auto-unsubscribe โ the standard idiom, detailed with RxJS) and lets services and directives participate in cleanup identically. New code should prefer DestroyRef registration at acquisition sites; ngOnDestroy remains fine and ubiquitous.
What needs no cleanup โ worth knowing to avoid cargo-cult teardown: template event bindings, host listeners, signals and computed, effects (self-disposing with their context), HttpClient single-shot requests, and viewChild queries. Angular manages its own machinery; you clean up what you acquired from outside it.
The Reactive Primitives That Replaced Hooks
effect() โ side effects when signals change. The workhorse for โwhen state changes, do something non-templateโ:
export class SearchPage { query = signal('');
constructor() { effect(() => { localStorage.setItem('lastQuery', this.query()); // runs now + on every change }); }}Effects auto-track their dependencies (signals guide explains how) and die with their component. Discipline: effects are for escaping reactivity (storage, logging, imperative libraries, analytics) โ deriving state inside an effect (effect(() => this.b.set(f(this.a())))) is the antipattern; thatโs computedโs job.
afterNextRender() / afterEveryRender() โ DOM-ready moments. The modern home for measure-and-integrate work:
constructor() { afterNextRender(() => { this.chart = new ChartJs(this.canvas().nativeElement); // DOM exists & painted this.destroyRef.onDestroy(() => this.chart.destroy()); });}These replaced most ngAfterViewInit uses with two improvements: they run only in the browser (SSR-safe โ server rendering never runs them), and their timing relative to actual rendering is explicit. Third-party widget integration, focus management, measurements โ this is their turf.
Reading Legacy: The Full Hook Table
Older codebases implement interfaces you should recognize on sight:
| Legacy hook | It ran whenโฆ | Modern replacement |
|---|---|---|
ngOnChanges(changes) | any @Input() changed | signal inputs + computed/effect |
ngOnInit | after first input binding | still used; often subsumed by derivations |
ngDoCheck | every CD pass (!) | almost never needed; was a perf foot-gun |
ngAfterViewInit | view/children ready | afterNextRender, viewChild signals |
ngAfterViewChecked | after every view check | afterEveryRender (rare) |
ngAfterContentInit/Checked | projected content ready | contentChild signals |
ngOnDestroy | teardown | still used; DestroyRef composable form |
The historical pain concentrated in ngOnChanges (a stringly-keyed SimpleChanges bag, easy to mishandle) and the *Checked hooks (running constantly โ a line of work in one could tank performance, and mutating state inside them caused the infamous ExpressionChangedAfterItHasBeenChecked error). If you meet that error in legacy code: state was changed during change detection โ the fix is restructuring when the mutation happens, and signal-era code makes the whole category rare.
Case Study: A Map Widgetโs Whole Life
The concepts pull together best in the integration scenario every team eventually meets โ wrapping an imperative third-party library (a map, a rich-text editor, a chart) in a component:
export class DeliveryMap { private destroyRef = inject(DestroyRef); mapEl = viewChild.required<ElementRef<HTMLElement>>('map'); markers = input.required<GeoPoint[]>();
private map: LeafletMap | null = null;
constructor() { afterNextRender(() => { // 1. birth: real DOM exists, browser-only โ safe for the library this.map = L.map(this.mapEl().nativeElement).setView([17.4, 78.5], 11);
// 3. death: declared beside birth this.destroyRef.onDestroy(() => this.map?.remove()); });
// 2. life: react to input changes, guarded until the map exists effect(() => { const points = this.markers(); if (!this.map) return; this.syncMarkers(points); }); }}Each phase lands in its designed slot: creation in afterNextRender (the element is painted, and SSR never executes it โ Leaflet touching window on a server would crash the render); updates as an effect on the input signal, so marker changes flow without any ngOnChanges bookkeeping โ note the guard for the window before the map initializes; destruction registered at the creation site via DestroyRef, so the imperative resource canโt outlive its component even if someone later refactors the class. Legacy versions of this exact component used ngAfterViewInit + ngOnChanges + ngOnDestroy across three methods with a shared nullable field โ the modern form colocates the whole lifecycle in the constructor, and the improvement in reviewability is the argument for the new primitives in miniature.
Frequently Asked Questions
Does ngOnInit run again if inputs change? No โ once per component life. Input changes are the domain of signal derivations (or legacy ngOnChanges). If youโre re-calling init logic manually on changes, youโve reinvented a derivation โ write it as one.
Where do I load initial data โ really? The layered answer: trivially, ngOnInit calling a service works. Better, a resource/derivation keyed on inputs (handles change + races). Best for route-level data, a route resolver or the component-input-binding pattern so navigation and data are coordinated. All three exist in real codebases; know which youโre reading.
Is the constructor-vs-ngOnInit distinction still tested in interviews? Constantly โ answer with the mechanism (inputs bind between the two), then earn bonus points by noting how signal inputs + derivations have shrunk ngOnInitโs role.
Can services have lifecycles? Root services live for the app (no destroy in practice); component-provided services die with their component โ and can inject DestroyRef for cleanup exactly like components. Thatโs the DI guideโs provider-scoping story.
What order do parent/child hooks fire in? Construction and init flow parentโchild; destruction childโparent (children die first). Occasionally matters for integration work; mostly trivia โ code depending subtly on sibling hook order is fragile by design.
The Interview Angle
Lifecycle questions are Angular-interview staples, and the era shift changed what good answers sound like. The classics and their modern framings:
โConstructor vs ngOnInit?โ โ give the mechanism (instance built โ inputs bound โ ngOnInit), then the modern coda: signal inputs plus derivations have shrunk ngOnInitโs role, since input-dependent setup is better expressed as computed/resource that also handles changes.
โHow do you prevent memory leaks?โ โ inventory the leak sources (global listeners, intervals, infinite subscriptions, third-party handles), then the tools by preference: framework-managed constructs need nothing; takeUntilDestroyed for streams; DestroyRef.onDestroy registered at acquisition; ngOnDestroy as the classic form. Mentioning registration at the acquisition site โ teardown declared beside setup โ signals real production experience.
โWhat is ExpressionChangedAfterItHasBeenChecked?โ โ the dev-mode double-check catching writes-during-render; the fix is moving the write to the right moment, not suppressing the check.
โHook execution order?โ โ know construction/init flows parentโchild and destroy reverses it, and say the honest second half: code depending on cross-component hook ordering is fragile by design, and signalsโ pull-based evaluation makes most such dependencies unnecessary.
The meta-signal interviewers listen for: whether you describe lifecycle as a list of methods to memorize or as three moments โ birth, change, death โ each with a modern reactive expression. The second framing is both more accurate and more hireable.
Finally, a scoping reminder that saves debugging time: lifecycle is per-instance, and instances are created and destroyed more often than beginners expect. Every @if toggle destroys and reconstructs its contents (fresh ngOnInit, fresh state); every @for insertion births instances and every removal destroys them (with track deciding which is which); route navigation destroys the old page component unless the same component-and-route is being reused. โWhy did my component reset?โ is almost always โbecause it was a new componentโ โ and DevToolsโ component tree, watched during the interaction, shows the churn directly.
Self-Check
constructor() { this.load(this.userId()); }withuserId = input.required<string>()โ what happens, and where does the call belong (two acceptable answers)?- A component adds
window.addEventListener('resize', this.onResize)inngOnInit. List the bug and two idiomatic fixes. - Why does initializing a Chart.js instance belong in
afterNextRenderrather thanngOnInitโ two distinct reasons? - Legacy code uses
ngOnChangesto recomputefullNamefromfirst/lastinputs. Write the modern one-liner. - Whatโs wrong with
effect(() => this.total.set(this.price() * this.qty()))?
(Answers: throws โ inputs unset at construction; ngOnInit, or better a derivation/resource keyed on the input. Listener outlives the component (leaking it via closure); fix with destroyRef.onDestroy(() => removeEventListener...) at the setup site, or a host-level/fromEvent+takeUntilDestroyed approach. The canvas element isnโt in the painted DOM at ngOnInit, and SSR would crash on browser-only APIs โ afterNextRender solves both. fullName = computed(() => \{this.last()}`). Deriving state in an effect โ write total = computed(โฆ)`; effects are for leaving reactivity, not feeding it.)
Next: Pipes โ formatting in templates, the built-in set, writing pure custom pipes, and why pipes beat method calls for display transforms.