Component lifecycle and DOM interaction
Replace the old lifecycle hooks with afterNextRender and signal queries, and touch the DOM through the renderer instead of querySelector.
The hooks that still apply
| Hook | Runs | Use it for |
|---|---|---|
ngOnInit | Once, after the first input binding | Starting work that needs inputs |
ngOnChanges | Before ngOnInit and on every input change | Legacy code; prefer computed or effect |
ngAfterViewInit | After the component's view is rendered | Measuring or focusing a view child |
ngAfterContentInit | After projected content is rendered | Working with contentChild results |
ngOnDestroy | Before the component is removed | Closing sockets, timers, third-party instances |
afterNextRender | Once, after the next render (browser only) | Anything that must not run on the server |
afterEveryRender | After every render (browser only) | Rare; usually a signal would do |
import { Component, viewChild, contentChild, ElementRef,
afterNextRender, afterEveryRender, inject, DestroyRef } from '@angular/core';
@Component({
selector: 'app-editor',
template: `
<div #toolbar class="toolbar"></div>
<textarea #input [value]="initial()"></textarea>
<ng-content select="[appEditorStatus]"></ng-content>
`
})
export class EditorComponent {
// Signal queries: a signal that resolves when the view exists.
readonly toolbar = viewChild.required<ElementRef<HTMLElement>>('toolbar');
readonly input = viewChild<ElementRef<HTMLTextAreaElement>>('input');
readonly statusSlot = contentChild<ElementRef<HTMLElement>>('appEditorStatus');
private readonly destroyRef = inject(DestroyRef);
constructor() {
// Safe in SSR: the callback is skipped on the server.
afterNextRender(() => {
this.input()?.nativeElement.focus();
this.measureToolbar();
});
// Cleanup registered once, tied to the component's lifetime.
this.destroyRef.onDestroy(() => this.chart?.destroy());
}
private chart?: { destroy(): void };
private measureToolbar() {
const width = this.toolbar().nativeElement.getBoundingClientRect().width;
console.log('toolbar width', width);
}
}💡
Signal queries are lazy: the signal is populated after the view is created. Reading
viewChild() inside the constructor returns undefined unless the query is required, in which case it throws. Read it in afterNextRender, in an effect, or in the template.Touching the DOM safely
import { Component, ElementRef, Renderer2, RendererStyleFlags2,
inject, signal } from '@angular/core';
@Component({
selector: 'app-drop-zone',
template: `<div #zone class="zone" role="button" tabindex="0">Drop files here</div>`
})
export class DropZoneComponent {
private readonly renderer = inject(Renderer2);
private readonly host = inject(ElementRef<HTMLElement>);
readonly dragging = signal(false);
constructor() {
// Renderer2 works on the server too and keeps the platform abstract.
this.renderer.listen(this.host.nativeElement, 'dragover', (event: DragEvent) => {
event.preventDefault();
this.dragging.set(true);
});
// Prefer classes over inline styles for anything a stylesheet might need.
this.renderer.addClass(this.host.nativeElement, 'zone--active');
// When you do need an inline style, use the flag that skips sanitisation
// only for constants you control.
this.renderer.setStyle(this.host.nativeElement, '--zone-gap', '12px',
RendererStyleFlags2.DashCase);
}
}document.querySelectorinside a component breaks encapsulation, finds elements outside the component, and throws on the server. Use a template reference withviewChild.Renderer2abstracts the platform, so the same code runs in a test double and on the server.- Host bindings on the component decorator (
host: { '[class.is-open]': 'open()' }) are the declarative route and are easier to test than imperative calls. - Listening in the constructor without cleanup is fine:
renderer.listenon the host element is bound to the component's lifetime. A global listener is not — register it withDestroyRef.
// Host bindings: declarative, testable, and no ElementRef at all.
@Component({
selector: 'app-panel',
template: `<ng-content />`,
host: {
'[class.is-open]': 'open()',
'[attr.aria-expanded]': 'open()',
'(keydown.escape)': 'close()'
}
})
export class PanelComponent {
readonly open = signal(false);
close() { this.open.set(false); }
}Pitfalls worth memorising
| Symptom | Cause | Fix |
|---|---|---|
viewChild() is undefined | Read before the view exists | Read it in afterNextRender or the template |
| Measurement is wrong by a few pixels | Read before fonts or layout settle | Measure in afterNextRender; re-measure on ResizeObserver |
SSR build fails on window | Direct global access during construction | Move it into afterNextRender or guard with isPlatformBrowser |
| A third-party chart leaks | It is only removed from the DOM | Call its destroy() in DestroyRef.onDestroy |
| Change detection runs forever | An afterEveryRender callback writes state read by the template | Move the work to afterNextRender or make it a computed |
| Focus lands in the wrong place | Focus set before the element is visible | Focus inside afterNextRender, after the panel is open |
// A ResizeObserver wired to the component lifetime: the pattern that
// prevents the classic "chart does not resize" bug.
import { Component, ElementRef, viewChild, afterNextRender, inject, DestroyRef, signal } from '@angular/core';
@Component({ selector: 'app-chart', template: `<canvas #canvas></canvas>` })
export class ChartComponent {
readonly canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
readonly width = signal(0);
private readonly destroyRef = inject(DestroyRef);
constructor() {
afterNextRender(() => {
const observer = new ResizeObserver(([entry]) => {
this.width.set(Math.round(entry.contentRect.width));
});
observer.observe(this.canvas().nativeElement.parentElement!);
this.destroyRef.onDestroy(() => observer.disconnect());
});
}
}FAQ
Should I still use ngAfterViewInit?
It works, but
afterNextRender is the modern equivalent and, importantly, it does not run on the server. For a component that will ever be server-rendered, using afterNextRender from the start saves a debugging session later.Is ElementRef always a bad idea?
No — it is the supported way to reach the host element and to measure. The problem is using it to search the document. Keep DOM access inside the component that owns the element, and let the template and queries find children rather than a selector string.
Related
Signals, computed values and effects Change detection, zoneless and performance
Last refreshed 2026-09-18.