Angular ElementRef, Renderer2, HostBinding, HostListener and Legacy Decorators

Angular Tutorial: Beginner to Advanced · 14. Directives and dynamic UI · 5 min read · Updated

Angular builds the DOM for you, but sometimes you must touch it yourself: set an attribute, add a listener, or work with a library that needs a real element. This lesson shows the tools, and the decorators you will meet in older code.

What you will learn

  • ElementRef and why direct DOM access is risky
  • Renderer2 for safe DOM changes
  • @HostBinding and @HostListener, and their modern host replacement
  • @Input, @Output and EventEmitter

ElementRef

ElementRef wraps the host element of a component or directive:

// src/app/dom/underline.ts
import { Directive, ElementRef, inject } from '@angular/core';

@Directive({ selector: '[appUnderline]' })
export class Underline {
  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);

  constructor() {
    this.el.nativeElement.style.textDecoration = 'underline';
  }
}

nativeElement is the real DOM node. Direct access works in the browser, but it has risks: code that touches document or nativeElement breaks during server-side rendering, and writing innerHTML can open XSS holes. Prefer template bindings, and use ElementRef only for reading or when no binding exists.

Renderer2

Renderer2 is an abstraction that changes the DOM in a way that also works on the server and with other platforms:

// src/app/dom/highlight-renderer.ts
import { Directive, ElementRef, Renderer2, inject } from '@angular/core';

@Directive({
  selector: '[appMark]',
  host: {
    '(mouseenter)': 'on()',
    '(mouseleave)': 'off()',
  },
})
export class Mark {
  private readonly el = inject(ElementRef);
  private readonly renderer = inject(Renderer2);

  protected on(): void {
    this.renderer.addClass(this.el.nativeElement, 'marked');
    this.renderer.setStyle(this.el.nativeElement, 'background', 'khaki');
    this.renderer.setAttribute(this.el.nativeElement, 'title', 'Marked');
  }

  protected off(): void {
    this.renderer.removeClass(this.el.nativeElement, 'marked');
    this.renderer.removeStyle(this.el.nativeElement, 'background');
    this.renderer.removeAttribute(this.el.nativeElement, 'title');
  }
}

Useful methods: addClass, removeClass, setStyle, removeStyle, setAttribute, removeAttribute, createElement, appendChild, removeChild, setProperty and listen (add an event listener that Angular removes for you). In most directives, the host property makes even Renderer2 unnecessary.

HostBinding and HostListener

The classic decorators bind to the host element:

// src/app/dom/legacy-host.ts
import { Directive, HostBinding, HostListener } from '@angular/core';

@Directive({ selector: '[appHover]' })
export class LegacyHover {
  @HostBinding('class.hovered') hovered = false;
  @HostBinding('style.cursor') cursor = 'pointer';

  @HostListener('mouseenter') onEnter(): void {
    this.hovered = true;
  }

  @HostListener('mouseleave') onLeave(): void {
    this.hovered = false;
  }

  @HostListener('window:resize', ['$event']) onResize(event: UIEvent): void {
    console.log('window resized', (event.target as Window).innerWidth);
  }
}

The modern version puts everything in the host object, in one place, and works with signals:

// src/app/dom/modern-host.ts
import { Directive, signal } from '@angular/core';

@Directive({
  selector: '[appHoverModern]',
  host: {
    '[class.hovered]': 'hovered()',
    '[style.cursor]': '"pointer"',
    '(mouseenter)': 'hovered.set(true)',
    '(mouseleave)': 'hovered.set(false)',
    '(window:resize)': 'onResize($event)',
  },
})
export class ModernHover {
  protected readonly hovered = signal(false);

  protected onResize(event: UIEvent): void {
    console.log('window resized', (event.target as Window).innerWidth);
  }
}

Angular removes these listeners when the element is destroyed.

@Input, @Output and EventEmitter

Before signals, inputs and outputs were decorators:

// src/app/dom/legacy-child.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'app-legacy-child',
  template: `
    <p>Hello, {{ name }}!</p>
    <button type="button" (click)="clicked.emit(name)">Say it</button>
  `,
})
export class LegacyChild {
  @Input() name = 'friend';
  @Input({ required: true }) id!: number;
  @Input({ transform: (v: string) => v.trim() }) label = '';

  @Output() clicked = new EventEmitter<string>();
}

The signal equivalents from earlier lessons are shorter and typed better:

  • `@Input() name = 'x'`: Signal style: name = input('x')
  • `@Input({ required: true }) id!: number`: Signal style: id = input.required<number>()
  • `@Output() clicked = new EventEmitter<string>()`: Signal style: clicked = output<string>()
  • `this.clicked.emit(v)`: Signal style: this.clicked.emit(v) (same)
  • `@Input() + @Output() valueChange`: Signal style: value = model('')

The migrations ng generate @angular/core:signal-input-migration and output-migration convert code for you. Both styles work side by side in one project. Decorators are the older generation, not removed.

Why Angular decorators matter: a decorator is a function that adds metadata to a class or property. @Component, @Directive, @Injectable and @Pipe describe the class. @Input, @Output, @HostBinding, @HostListener, @ViewChild and @ContentChild describe members. In new code many of these became functions (input, output, viewChild) or configuration (host).

Put it together

// src/app/dom/dom-page.ts
import { Component } from '@angular/core';
import { LegacyChild } from './legacy-child';
import { LegacyHover } from './legacy-host';
import { Mark } from './highlight-renderer';
import { ModernHover } from './modern-host';
import { Underline } from './underline';

@Component({
  selector: 'app-dom-page',
  imports: [LegacyChild, LegacyHover, Mark, ModernHover, Underline],
  template: `
    <p appUnderline>Underlined by ElementRef</p>
    <p appMark>Hover me (Renderer2)</p>
    <p appHover>Hover me (legacy decorators)</p>
    <p appHoverModern>Hover me (host property)</p>
    <app-legacy-child name="Ada" [id]="1" label=" x " (clicked)="last = $event" />
    <p>Last clicked: {{ last }}</p>
  `,
  styles: `.hovered { background: lightblue; }`,
})
export class DomPage {
  protected last = '';
}
// src/app/app.ts
import { Component } from '@angular/core';
import { DomPage } from './dom/dom-page';

@Component({
  imports: [DomPage],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <app-dom-page />
</main>

Rules of thumb

  1. Binding first. Use [class], [style], [attr] and host before touching the DOM.
  2. Renderer2 over direct DOM changes when you must change something.
  3. Never build HTML strings from user input.
  4. Guard browser-only code (afterNextRender) so server rendering works.

Try it yourself

  1. Write a directive appBackground that sets a background colour with Renderer2 from an input.
  2. Convert LegacyChild to signal inputs and outputs.
  3. Add a (keydown.escape) listener to a directive using the host property.

Quick check

  1. Why prefer Renderer2 over changing nativeElement directly?
  2. What replaces @HostBinding and @HostListener in modern code?
  3. What is the signal version of @Output() with EventEmitter?

Next, continue with Angular Injection Tokens and Providers: Configuration and Testing.

Frequently asked questions

Can I still use EventEmitter?

Yes. output() creates a simpler, signal-era output, but EventEmitter keeps working and is common in libraries and older apps.

Is HostListener deprecated?

It is not removed, but the host property is the recommended style for new code.