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
ResizeObservercallbacks, so any callback that runs inside the Angular zone triggers application-wide change detection when it finishes. Creating the observer insideNgZone.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.onDestroyruns when the directive's host is destroyed, including elements removed by@ifand@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.
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
@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
// 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); };
}
}
// 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);
});
}
}
<!-- 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.
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.
Verification Steps
- Angular DevTools profiler: resizing should show refreshes only at breakpoint changes.
- Heap snapshot: one
ResizeObserverinstance for the app. - Remove cards with
@forand confirm elements are unobserved (no detached cards retained). - SSR build: the directive must not touch
ResizeObserveron 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
afterNextRenderfor browser-only setup after the element renders. ngOnDestroyon a service for per-element cleanup. Use the directive'sDestroyRef.
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.
Related
- Running Observer Callbacks Outside NgZone — the zone mechanics
- Lazy Loading Images with an Angular IntersectionObserver Directive — the visibility counterpart
- Building a Shared ResizeObserver Service — the framework-agnostic service
↑ Back to Angular Observer Directives