What makes a good Angular library
Let's explore how we can consume diverse Web APIs in Angular application properly by abstracting it into a library built "the Angular way".

Web is rich. We can navigate VR world using gamepad, play synthesizers with MIDI keyboard and buy goods with a touch of a finger. All these spectacular capabilities are available through native browser APIs. And they are just as diverse as the features they provide.
Angular is an amazing platform with some of the best tools across the front-end scene. It means there is an “Angular way” of doing things. What I personally love about this framework is when things are done well — it gives you great satisfaction of tidy code and solid architecture. Let’s talk about what makes code properly designed for Angular.
The Angular way
I’ve been working with Angular for a while now, learning from the great engineers I work with and from huge pool of knowledge kindly exposed on the internet. Some time ago I noticed, that while there is a lot of impressive tools browsers provide these days — not a lot is included with Angular out of the box. This is purposefully, of course, since it’s a platform we build experiences around and it’s up to us to tailor it to our needs. That’s why I started an open-source initiative called Web APIs for Angular. Our goal is to create lightweight, high quality idiomatic libraries to consume native APIs in Angular applications. I’d like to discuss what principles can help you write good code pulling examples from @ng-web-apis/intersection-observer library.
In my experience, these 3 core concepts play the biggest role:
- Angular is declarative in its nature whilst most native code we use is imperative
- Angular has awesome Dependency Injection system which you can use to your advantage
- Angular is Observable-based as opposed to mostly callback-based native code
Let’s go over each of them in detail.
Declarative vs. Imperative
Here’s a typical code snippet you would have if you plan on using IntersectonObserver
:
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'));
There is not much code here but it breaks pretty much all 3 principles outlined above. In Angular we would abstract such logic into a directive and configure everything declaratively:
<div
waIntersectionThreshold="1"
waIntersectionRootMargin="0px"
(waIntersectionObserver)="onIntersection($event)"
>
I'm being observed
</div>
You can read more about declarative nature of Angular directives and how we use it to work with Payment Request API here. I highly recommend you give it a shot since here there’s too little code to properly explore the subject.
Inside that directive we would delegate all the work to a service. This way we can use it in case we need to observe a host component. It will also abstract over imperative observe
/unobserve
calls.
Intersection observer service and the need for a root element to observe intersections brings us to the second principle — DI.
Dependency Injection
We often use DI to inject some built-in Angular entities or services we create. But it could bring a lot more. I’m talking about providers, factories, tokens and all that. For example, our directive need to access root element to observe intersections. We can provide ElementRef
by token and a simple second directive:
@Directive({
selector: '[waIntersectionRoot]',
providers: [
{
provide: INTERSECTION_ROOT,
useExisting: ElementRef,
},
],
})
export class IntersectionRootDirective {}
Then our template starts to look like this:
<div waIntersectionRoot>
...
<div
waIntersectionThreshold="1"
waIntersectionRootMargin="0px"
(waIntersectionObserver)="onIntersection($event)"
>
I'm being observed
</div>
...
</div>
You can read more about DI and how you may leverage its power in an article about our declarative Web Audio API library for Angular
Tokens are powerful because they allow us to bring more separation to the code. For example we can provide host component as root if we want to observe intersections of some scrollable component with its children.
Angular has built-in tokens one of which is DOCUMENT
. It can help us access document
in a safe way. It makes our code easier to test and also abstracts over global object for environments like Server Side Rendering.
We can add our own helper tokens to access other global objects, such as window
or navigator
. While you may find articles over internet with extremely convoluted ways to make window
token it’s actually fairly easy:
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;
},
},
);
All we need to do is reach for Angular’s DOCUMENT
and access its defaultView
. We’ve created a few of such tokens in our @ng-web-apis/common library.
In Intersection Observer library we will also use tokens to configure our service which we’ll discuss in the next section.
Observables
While native APIs are typically callback-based or, at best, use Promises, we in Angular rely on RxJs and its reactive paradigm. An often overlooked feature of Observable
is that it’s a class and thus can be extended. Let’s create a service abstraction of IntersectionObserver
that would turn it into an Observable
. We already have a token for root element and we discussed creating tokens for other configuration parameters:
@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(),
);
}
}
Now we have an Observable
that encapsulates an IntersectionObserver
. We can even use it outside Angular by simply passing all parameters into a new
call.
We used similar approach to create an Observable
service is used in Geolocation API library
But how do we provide those configuration tokens with our directive? Thanks to Angular’s DI system we can grab them straight from attributes with useFactory
:
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$;
}
}
Now we can either use directive in template or inject a service and chain it with familiar RxJs operators such as map
, filter
or switchMap
to get the logic we need.
Conclusion
We followed all 3 principles to create an Observable-based declarative Intersection Observer library. We can use it in any way we like thanks to DI and tokens. It is just about 1KB gzip and you can grab it from npm or GitHub.
I hope this information will help you write beautiful and robust applications. It sure helps me feel good about what I do and we will continue to cover awesome Web APIs. If you want to try something more exotic with Angular, like building your guitar processor or playable synthesizer with MIDI controls — you are very welcome to take a look at all of our releases on GitHub.
Edit: Version 2.0
Having a single IntersectionObserver
per element might be detrimental to memory if you plan on observing many elements with same parameters. So in version 2.0.0 of our library we changed the API. We still have IntersectionObserverService
that relies on tokens in case you need to observe each element individually. But directives have changed to IntersectionObserverDirective
and IntersectionObserveeDirective
. Here’s how they work:
IntersectionObserverDirective
extends native observer and provides method to register new element and its callbackIntersectionObserveeDirective
injects it and registers itself- Upon intersection, parent directive filters intersection entries by the element they are related to and only triggers respective callbacks:
@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();
}
}
Observee directive relies on a service, like before:
@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());
}
}
Now we can save a little bit of memory every time we observe multiple elements!