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

HookRunsUse it for
ngOnInitOnce, after the first input bindingStarting work that needs inputs
ngOnChangesBefore ngOnInit and on every input changeLegacy code; prefer computed or effect
ngAfterViewInitAfter the component's view is renderedMeasuring or focusing a view child
ngAfterContentInitAfter projected content is renderedWorking with contentChild results
ngOnDestroyBefore the component is removedClosing sockets, timers, third-party instances
afterNextRenderOnce, after the next render (browser only)Anything that must not run on the server
afterEveryRenderAfter 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.querySelector inside a component breaks encapsulation, finds elements outside the component, and throws on the server. Use a template reference with viewChild.
  • Renderer2 abstracts 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.listen on the host element is bound to the component's lifetime. A global listener is not — register it with DestroyRef.
// 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

SymptomCauseFix
viewChild() is undefinedRead before the view existsRead it in afterNextRender or the template
Measurement is wrong by a few pixelsRead before fonts or layout settleMeasure in afterNextRender; re-measure on ResizeObserver
SSR build fails on windowDirect global access during constructionMove it into afterNextRender or guard with isPlatformBrowser
A third-party chart leaksIt is only removed from the DOMCall its destroy() in DestroyRef.onDestroy
Change detection runs foreverAn afterEveryRender callback writes state read by the templateMove the work to afterNextRender or make it a computed
Focus lands in the wrong placeFocus set before the element is visibleFocus 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.

Signals, computed values and effects Change detection, zoneless and performance

Last refreshed 2026-09-18.