Technology  /  Angular

๐Ÿ…ฐ๏ธ Angular 15 guides ยท updated 2026

Standalone components, signals, RxJS, and dependency injection โ€” building maintainable frontends with the framework enterprises trust.

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 moment

Three 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 hookIt ran whenโ€ฆModern replacement
ngOnChanges(changes)any @Input() changedsignal inputs + computed/effect
ngOnInitafter first input bindingstill used; often subsumed by derivations
ngDoCheckevery CD pass (!)almost never needed; was a perf foot-gun
ngAfterViewInitview/children readyafterNextRender, viewChild signals
ngAfterViewCheckedafter every view checkafterEveryRender (rare)
ngAfterContentInit/Checkedprojected content readycontentChild signals
ngOnDestroyteardownstill 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

  1. constructor() { this.load(this.userId()); } with userId = input.required<string>() โ€” what happens, and where does the call belong (two acceptable answers)?
  2. A component adds window.addEventListener('resize', this.onResize) in ngOnInit. List the bug and two idiomatic fixes.
  3. Why does initializing a Chart.js instance belong in afterNextRender rather than ngOnInit โ€” two distinct reasons?
  4. Legacy code uses ngOnChanges to recompute fullName from first/last inputs. Write the modern one-liner.
  5. 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.first(){this.first()} {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.