Build the Angular resize directive as a standalone directive that injects a root ResizeService (one ResizeObserver for the app, created outside the zone), writes size into signals only when the value that templates care about changes, and releases its element through DestroyRef — so templates read size.breakpoint() without triggering change detection on every pixel.

Problem / Scenario Context

An Angular dashboard has a (resized) directive that creates a ResizeObserver per element and emits { width, height } through an @Output() on every callback. Parent components bind to it and store the size in fields. During a sidebar animation, 40 cards emit every frame; each emission runs zone-triggered change detection across the whole app, and card templates that only care whether they are "compact" re-evaluate their bindings every frame. Profiles show change detection dominating the animation.

Signals and zoneless-friendly patterns make this cheap. The Angular Observer Directives topic covers directive basics; this page builds the resize directive with signals.

Mechanics Explanation

Three Angular mechanisms matter:

  • Zone.js patches browser APIs, including ResizeObserver callbacks, so any callback that runs inside the Angular zone triggers application-wide change detection when it finishes. Creating the observer inside NgZone.runOutsideAngular() prevents that — covered in depth in running observer callbacks outside NgZone.
  • Signals notify only their consumers. Setting a signal from outside the zone marks the templates that read it for refresh (in zoneless apps directly; in zone apps on the next change detection), without re-checking unrelated components. Setting a signal to an equal value is a no-op.
  • DestroyRef.onDestroy runs when the directive's host is destroyed, including elements removed by @if and @for, which is where the element must be unobserved.

A directive that writes the raw width to a signal still notifies consumers every pixel. Deriving a breakpoint signal with computed() helps only if nothing reads the raw width; better, write the breakpoint signal directly, so it only changes when crossing thresholds.

Resize Entry to Template UpdateFour boxes. The shared ResizeObserver, created outside the Angular zone, delivers an entry without triggering change detection. The directive computes the breakpoint from the entry. The breakpoint signal is set, which is a no-op when the value is unchanged. Only templates reading that signal are refreshed, and only when the breakpoint actually changes.Shared RO, outsidezoneno app-wide changedetectionDirectivebreakpoint from entrysignal.set()no-op if unchangedReaders refreshonly on breakpointchange

Comparison Table: Directive Designs

Design Change detection per resize frame Observers Template reads
@Output() per callback, inside zone whole app, every frame one per element fields updated by parent
@Output(), outside zone, emit on change parent only, on change one per element fields
Signals, raw width readers, every frame shared width()
Signals, breakpoint only readers, on change shared breakpoint()
CSS container queries none none none

Minimal Reproducible Example

TypeScript
@Directive({ selector: '[resized]', standalone: true })
export class ResizedDirective implements OnInit, OnDestroy {
  @Output() resized = new EventEmitter<{ width: number; height: number }>();
  private ro?: ResizeObserver;
  constructor(private el: ElementRef<HTMLElement>) {}
  ngOnInit() {
    this.ro = new ResizeObserver(([e]) => this.resized.emit(e.contentRect));   // inside the zone
    this.ro.observe(this.el.nativeElement);
  }
  ngOnDestroy() { this.ro?.disconnect(); }
}

Every frame of a resize, every card emits and the whole app runs change detection.

Production-Safe Solution

TypeScript
// resize.service.ts — one ResizeObserver for the app, outside the zone.
import { Injectable, NgZone, inject } from '@angular/core';

type Handler = (e: ResizeObserverEntry) => void;

@Injectable({ providedIn: 'root' })
export class ResizeService {
  private zone = inject(NgZone);
  private handlers = new Map<Element, Handler>();
  private ro?: ResizeObserver;

  observe(el: Element, handler: Handler, box: ResizeObserverBoxOptions = 'content-box'): () => void {
    if (typeof ResizeObserver === 'undefined') return () => {};            // SSR
    this.ro ??= this.zone.runOutsideAngular(() =>
      new ResizeObserver((entries) => entries.forEach((e) => this.handlers.get(e.target)?.(e))));
    this.handlers.set(el, handler);
    this.ro.observe(el, { box });
    return () => { this.handlers.delete(el); this.ro?.unobserve(el); };
  }
}
TypeScript
// size.directive.ts — standalone, signals, DestroyRef.
import { Directive, ElementRef, DestroyRef, inject, input, output, signal, afterNextRender } from '@angular/core';
import { ResizeService } from './resize.service';

export type Breakpoint = 'sm' | 'md' | 'lg';

@Directive({ selector: '[appSize]', standalone: true, exportAs: 'appSize' })
export class SizeDirective {
  readonly breakpoints = input<[number, number]>([360, 720]);     // sm < 360 ≤ md < 720 ≤ lg
  readonly breakpointChange = output<Breakpoint>();

  readonly breakpoint = signal<Breakpoint | undefined>(undefined);
  readonly width = signal(0);                                       // opt-in raw value, read sparingly

  private el = inject(ElementRef<HTMLElement>);
  private service = inject(ResizeService);
  private destroyRef = inject(DestroyRef);

  constructor() {
    afterNextRender(() => {                                         // browser only, after first render
      const stop = this.service.observe(this.el.nativeElement, (e) => {
        const w = Math.round(e.contentBoxSize[0].inlineSize);
        this.width.set(w);
        const [a, b] = this.breakpoints();
        const next: Breakpoint = w < a ? 'sm' : w < b ? 'md' : 'lg';
        if (next !== this.breakpoint()) {
          this.breakpoint.set(next);                                // readers refresh only here
          this.breakpointChange.emit(next);
        }
      });
      this.destroyRef.onDestroy(stop);
    });
  }
}
HTML
<!-- card.component.html -->
<article appSize #size="appSize" [class.compact]="size.breakpoint() === 'sm'">
  @if (size.breakpoint() !== 'sm') { <app-card-details /> }
  <app-card-summary />
</article>

The observer's callbacks run outside the zone, so they never trigger global change detection. The breakpoint signal changes only when a threshold is crossed, and only the template that reads it is refreshed. afterNextRender keeps the observer out of server rendering and ensures the element exists. DestroyRef releases the element when the card is removed by @for or @if.

Change Detection Work During a Sidebar AnimationA bar chart of change detection cycles over a one-second sidebar animation with forty cards. Outputs emitted inside the zone on every callback caused about sixty application-wide cycles. Signals updated with the raw width caused about sixty refreshes limited to the cards. Signals updated only on breakpoint changes caused a handful of card refreshes.40 cards, 1 s sidebar animationOutput in zone, every frame~60 app-wide cyclessignal(width), every frame~60 card refreshessignal(breakpoint), on change~3 card refreshes

Zoneless Angular

In zoneless applications (provideZonelessChangeDetection()), there is no zone to escape: change detection is scheduled by signal changes, markForCheck, and a few other notifications. The directive above works unchanged — runOutsideAngular is harmless — and its design matters even more, because a signal set every frame schedules a refresh every frame. Updating only the breakpoint signal keeps zoneless apps idle during resizes.

@Output() emitters and the new output() API do not themselves trigger change detection in zoneless mode; the parent's handler must update a signal or call markForCheck if it changes template state.

Zone-Based Versus Zoneless BehaviourTwo columns. In zone-based apps, an observer created inside the zone triggers application-wide change detection after every callback, so it must be created outside the zone, and signals then refresh only their readers. In zoneless apps there is no zone to escape; signal changes alone schedule refreshes, so writing signals only when values change is what keeps resizes cheap.Zone-based appCallbacks inside the zone: app-wide CDCreate the observer outside the zoneSignals refresh only their readersZoneless appNo zone: nothing to escapeSignal changes schedule refreshesSet signals only when values change

Verification Steps

  • Angular DevTools profiler: resizing should show refreshes only at breakpoint changes.
  • Heap snapshot: one ResizeObserver instance for the app.
  • Remove cards with @for and confirm elements are unobserved (no detached cards retained).
  • SSR build: the directive must not touch ResizeObserver on the server.
  • Zoneless mode: confirm templates still update on breakpoint changes.

Common Mistakes to Avoid

  • Creating observers inside the zone. Every callback triggers app-wide change detection.
  • Setting a signal every frame. Readers refresh every frame; set only derived values that change.
  • Observing in the constructor. Use afterNextRender for browser-only setup after the element renders.
  • ngOnDestroy on a service for per-element cleanup. Use the directive's DestroyRef.

FAQ

Why run the ResizeObserver outside NgZone?

Zone.js patches ResizeObserver callbacks, so in zone-based apps each callback would trigger change detection for the whole application. Outside the zone, only explicit signal changes or markForCheck cause template updates.

Do signals set outside the zone update the template?

Yes. Signals notify their consumers regardless of zones. In zone-based apps the refresh happens on the next change detection, which Angular schedules for signal-driven views; in zoneless apps it is scheduled directly.

Why afterNextRender instead of ngOnInit?

afterNextRender runs only in the browser and after the view has rendered, so the element exists and ResizeObserver is available. ngOnInit also runs during server rendering.

Can templates read the raw width?

They can via width(), but every read subscribes the template to per-pixel updates. Prefer the breakpoint signal, or a computed signal derived from width that returns a coarse value.

Is exportAs needed?

It lets the template refer to the directive instance with a template reference variable, as in #size="appSize", so the template can read its signals directly.

Should I still use container queries?

Yes, for pure styling. Use the directive when the size changes which components render, as in the @if above, or when component code needs the value.


↑ Back to Angular Observer Directives