Designing idiomatic Angular libraries
The modern web is a rich environment. Users can strap on a VR headset and navigate through virtual worlds with a gamepad, compose music using a MIDI keyboard, or make purchases with a simple tap. All of these impressive features are made possible through native browser APIs that come in many different shapes and sizes.
Angular stands out as a powerful platform, offering some of the most refined tools in the front-end ecosystem. This brings us to what is often called the “Angular way” — a preferred approach to solving problems within this framework. What I appreciate most is the sense of clarity and solid structure that comes from a well-crafted Angular solution. Let’s break down what separates good code from great code in the Angular world.
Embracing the framework's philosophy
Having spent a considerable amount of time with Angular, I’ve learned a great deal from talented colleagues and the abundant resources shared by the community. Over time, I’ve noticed that, while browsers provide an incredible array of tools, Angular deliberately excludes many of them from its core. This is an intentional choice — it’s a platform built for creating experiences, and it’s our responsibility to shape it as needed. This realization led me to launch an open-source project called Web APIs for Angular. Our mission is to develop lightweight, high-quality libraries that make it easy to use native APIs within Angular applications. I’d like to share some of the principles behind this effort, using the @ng-web-apis/intersection-observer library as a concrete example.
Based on my experience, three fundamental ideas are key:
- Angular is inherently declarative, while most native APIs lean toward an imperative style
- Angular’s powerful Dependency Injection system opens up many possibilities beyond what you might initially expect
- Angular is built around Observables, whereas native code frequently relies on callbacks
Let's dive deeper into each of these concepts.
From imperative to declarative
Here's a typical example of what you might write to work with the [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API):
const callback = entries => { ... };
const options = {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: 1.0
}
const observer = new IntersectionObserver(callback, options);
observer.observe(document.querySelector('#target'));
The snippet is short, yet it violates all three of the principles mentioned earlier. In an Angular context, we’d typically wrap such logic in a directive and set everything up in a declarative manner:
<div
waIntersectionThreshold="1"
waIntersectionRootMargin="0px"
(waIntersectionObserver)="onIntersection($event)"
>
I'm being observed
</div>
I've written more about the declarative nature of directives and how we applied it to work with the Payment Request API. I highly recommend exploring that piece, as this short snippet doesn't provide much room for a thorough discussion.
Within that directive, we can hand off all the heavy lifting to a dedicated service. This service would then be available in cases where you need to observe a host component too. It also helps to encapsulate the imperative observe and unobserve method calls.
The need for a root element within the service brings us to our second principle — the power of DI.
Leveraging dependency injection
It’s common to use DI for injecting Angular’s built-in entities or our own services. However, its utility goes much further. I'm considering providers, factories, tokens, and similar features. For instance, to observe intersections, our directive needs access to a root element. We can supply an ElementRef token and use a small helper directive:
@Directive({
selector: '[waIntersectionRoot]',
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
})
export class IntersectionRootDirective {}
The corresponding template would then look something like this:
<div waIntersectionRoot>
...
<div
waIntersectionThreshold="1"
waIntersectionRootMargin="0px"
(waIntersectionObserver)="onIntersection($event)"
>
I'm being observed
</div>
...
</div>
Learn more about DI and how to use it effectively in an article on our declarative Web Audio API library.
Tokens are useful because they promote better separation of concerns. We can, for example, designate a host component as the root if you want to track intersections of a scrollable element with its children.
Angular ships with built-in tokens, one of which is DOCUMENT. This provides a safe way to access the document object, making our code easier to test and ensuring compatibility with environments like Server Side Rendering.
We can create our own helper tokens for other global objects like window or navigator. While you might find overly complicated tutorials online for creating a window token, the process is actually quite simple:
import {DOCUMENT} from '@angular/common';
import {inject, InjectionToken} from '@angular/core';
export const WINDOW = new InjectionToken<Window>(
'An abstraction over global window object',
{
factory: () => {
const {defaultView} = inject(DOCUMENT);
if (!defaultView) {
throw new Error('Window is not available');
}
return defaultView;
},
},
);
The trick is to use Angular’s DOCUMENT token and access its defaultView property. We've created a set of these tokens in our @ng-web-apis/common library.
In the Intersection Observer library, we'll also utilize tokens to configure our service, which is covered in the next section.
Reactivity with Observables
Most native APIs rely on callbacks, or at best, Promises. Angular, however, is built around RxJs and reactive programming. One often overlooked aspect of an Observable is that it is a class and can therefore be subclassed. Let's build a service from IntersectionObserver that converts it into an Observable. We already have a token for the root element, and we discussed creating tokens for other configuration options:
@Injectable()
export class IntersectionObserverService extends Observable<IntersectionObserverEntry[]> {
constructor(
@Inject(ElementRef) {nativeElement}: ElementRef<Element>,
@Inject(INTERSECTION_OBSERVER_SUPPORT) support: boolean,
@Optional() @Inject(INTERSECTION_ROOT) root: ElementRef<Element> | null,
@Optional() @Inject(INTERSECTION_ROOT_MARGIN) rootMargin: string | null,
@Optional() @Inject(INTERSECTION_THRESHOLD) threshold: number | number[] | null,
) {
let observer: IntersectionObserver;
super(subscriber => {
if (!support) {
subscriber.error('IntersectionObserver is not supported in your browser');
}
observer = new IntersectionObserver(
entries => {
subscriber.next(entries);
},
{
root: root ? root.nativeElement : undefined,
rootMargin: rootMargin ? rootMargin : undefined,
threshold: threshold ? threshold : undefined,
},
);
observer.observe(nativeElement);
});
return this.pipe(
finalize(() => observer.disconnect()),
share(),
);
}
}
We now have an Observable that wraps the logic of IntersectionObserver. This abstraction is so flexible that it can even be used outside of Angular by simply passing the parameters to the constructor.
A similar pattern of creating an
Observableservice is used in our Geolocation API library.
How do we supply those configuration tokens for use with our directive? Angular’s DI system allows us to extract values directly from attributes using the useFactory provider:
export function rootMarginFactory(rootMargin: string | null): string | null {
return rootMargin;
}
export function thresholdFactory(threshold: string | null): number[] | null {
return threshold ? threshold.split(',').map(parseFloat) : null;
}
@Directive({
selector: '[waIntersectionObserver]',
providers: [
IntersectionObserverService,
{
provide: INTERSECTION_ROOT_MARGIN,
deps: [[new Attribute('waIntersectionRootMargin')]],
useFactory: rootMarginFactory,
},
{
provide: INTERSECTION_THRESHOLD,
deps: [[new Attribute('waIntersectionThreshold')]],
useFactory: thresholdFactory,
},
],
})
export class IntersectionObserverDirective {
@Output()
readonly waIntersectionObserver: Observable<IntersectionObserverEntry[]>;
constructor(
@Inject(IntersectionObserverService)
entries$: Observable<IntersectionObserverEntry[]>,
) {
this.waIntersectionObserver = entries$;
}
}
You can now either apply the directive in your template or inject the service and use familiar RxJs operators like map, filter, or switchMap to build the logic you need.
Wrapping up
By following these three principles, we successfully created a declarative, Observable-based Intersection Observer library. With the help of DI and tokens, it offers great flexibility in its usage. The library is only about 1KB in size (gzipped) and is available on npm and GitHub.
I hope these insights assist you in crafting more elegant and maintainable applications. This approach certainly gives me a sense of accomplishment, and we plan to continue building on it for other Web APIs. If you're keen to experiment further—perhaps building a custom guitar processor or a playable synthesizer with MIDI support—feel free to check out all of our other libraries on GitHub.
Update: Version 2.0
Maintaining an individual IntersectionObserver for each element can be taxing on memory, especially when you are monitoring many elements with the same parameters. As a result, we revised the API for version 2.0.0 of the library. The IntersectionObserverService that relies on tokens remains for those who need to observe elements one by one. However, the directives have been replaced by IntersectionObserv**er**Directive and IntersectionObserv**ee**Directive. Here’s a look at their interaction:
IntersectionObserv**er**Directiveextends the native observer, offering a way to register an element and its callbackIntersectionObserv**ee**Directiveinjects the observer directive and registers itself with it- When an intersection occurs, the parent directive identifies the corresponding element and triggers the correct callback functions:
@Directive({
selector: '[waIntersectionObserver]',
})
export class IntersectionObserverDirective extends IntersectionObserver
implements OnDestroy {
private readonly callbacks = new Map<Element, IntersectionObserverCallback>();
constructor(
@Optional() @Inject(INTERSECTION_ROOT) root: ElementRef<Element> | null,
@Attribute('waIntersectionRootMargin') rootMargin: string | null,
@Attribute('waIntersectionThreshold') threshold: string | null,
) {
super(
entries => {
this.callbacks.forEach((callback, element) => {
const filtered = entries.filter(({target}) => target === element);
if (filtered.length) {
callback(filtered, this);
}
});
},
{
root: root && root.nativeElement,
rootMargin: rootMarginFactory(rootMargin),
threshold: thresholdFactory(threshold),
},
);
}
observe(target: Element, callback: IntersectionObserverCallback = () => {}) {
super.observe(target);
this.callbacks.set(target, callback);
}
unobserve(target: Element) {
super.unobserve(target);
this.callbacks.delete(target);
}
ngOnDestroy() {
this.disconnect();
}
}
The observee directive works through a service, as it did previously:
@Injectable()
export class IntersectionObserveeService extends
Observable<IntersectionObserverEntry[]> {
constructor(
@Inject(ElementRef) {nativeElement}: ElementRef<Element>,
@Inject(IntersectionObserverDirective)
observer: IntersectionObserverDirective,
) {
super(subscriber => {
observer.observe(nativeElement, entries => {
subscriber.next(entries);
});
return () => {
observer.unobserve(nativeElement);
};
});
return this.pipe(share());
}
}
This approach helps conserve some memory whenever you need to observe multiple elements at once!
