I was building some chart components and I wanted to make them responsive. My main problem was:
How do I get the dimensions of the chart component’s host element whenever it is resized so that I can adjust the SVG and the shapes/scales within it?
The browser's ResizeObserver exists just for this purpose. As we look at its API, we see that one instance of a ResizeObserver can observe multiple DOM elements (the callback you provide accepts a list of DOM elements that have been resized).
In an Angular project, this suggests having one root service, let’s say a ResizeObserverService
, that can keep track of the nodes we want to observe. When a node is observed through this service, we can expose a signal that updates as the node is resized.
@Injectable({
providedIn: 'root'
})
export class ResizeObserverService {
private elementToResizeSignals = new Map<Element, WritableSignal<DOMRectReadOnly>>();
private resizeObserver: ResizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
const dimensionSignal = this.elementToResizeSignals.get(entry.target);
if (dimensionSignal) {
dimensionSignal.set(entry.contentRect);
}
}
});
public observeResize(elementRef: ElementRef<Element>): Signal<DOMRectReadOnly> {
const dimensions = signal(elementRef.nativeElement.getBoundingClientRect());
this.elementToResizeSignals.set(elementRef.nativeElement, dimensions);
this.resizeObserver.observe(elementRef.nativeElement);
return dimensions;
}
public unObserve(elementRef: ElementRef<Element>): void {
this.resizeObserver.unobserve(elementRef.nativeElement);
this.elementToResizeSignals.delete(elementRef.nativeElement);
}
}
Note the use of a Map
which allows us to store object references (in this case the DOM nodes) as keys.
From the perspective of using this in a component, we’d have to observeResize()
on init and unObserve()
when the component is destroyed. It’s a fine approach but adding ngOnInit
and ngOnDestroy
to every component where we want to use this still seems tedious. Can we make this simpler?
This is where Angular’s host directives come in. These are just directives you can apply to an Angular component using the hostDirectives
property in the component decorator. A host directive goes through the same lifecycle as the component that it is applied to. So if we were to define a ResizeDirective
and apply it to a component, it would init and destroy with the component.
@Directive({
selector: '[appResize]',
standalone: true
})
export class ResizeDirective implements OnDestroy {
private resizeObserverService = inject(ResizeObserverService);
private hostElementRef = inject(ElementRef);
public dimensions = this.resizeObserverService.observeResize(this.hostElementRef);
ngOnDestroy() {
this.resizeObserverService.unObserve(this.hostElementRef);
}
}
Note how with dependency injection we can get access to the host component’s ElementRef
.
But how do we get access to the directive’s dimensions
signal in our component? We first need to apply the ResizeDirective
to the component through the hostDirectives
property in the component decorator. Then we can get the directive instance with dependency injection to access its dimensions
signal:
@Component({
…
hostDirectives: [ResizeDirective]
})
export class DonutChartComponent<T> implements AfterViewInit {
private dimensions = inject(ResizeDirective).dimensions;
// ... do stuff when host's dimensions change
}
And that’s it! Whenever we want to access a component's host element's dimensions, we just apply this ResizeDirective
to it as a hostDirective
and we get a dimensions
signal that we can do stuff with, all without worrying about cleanup or having to deal with the ResizeObserver
callback.