Global objects in Angular

In JavaScript we often use entities, such as window or navigator. Some of these objects have been there forever. But you might have seen DOCUMENT token used in Angular. Let's discuss why it exists and what we can learn from it to make our apps cleaner and more flexible.

Global objects in Angular

In JavaScript we often use entities, such as window, navigator, requestAnimationFrame or location. Some of these objects have been there forever, some are parts of the ever-growing Web APIs feature set. You might have seen Location class or DOCUMENT token used in Angular. Let's discuss why they exist and what we can learn from them to make our apps cleaner and more flexible.

DOCUMENT

DOCUMENT is a built-in Angular token. Here's how you can use it. Instead of this:

constructor(@Inject(ElementRef) private readonly elementRef: ElementRef) {} 

get isFocused(): boolean {
  return document.activeElement === this.elementRef.nativeElement;
}

You can write this:

constructor(
  @Inject(ElementRef) private readonly elementRef: ElementRef,
  @Inject(DOCUMENT) private readonly documentRef: Document,
) {} 

get isFocused(): boolean {
  return this.documentRef.activeElement === this.elementRef.nativeElement;
}

In the first code snippet we accessed global variable directly. In the second example we injected it as a dependency to constructor. I'm not going to throw fancy acronyms at you or say that the first approach breaks some programming principles or patterns. To be honest, I'm not that fluent in those. Instead, I will show you exactly why token approach is better. And we will start by taking a look at where this token comes from. There's nothing special about its declaration in the @angular/common package:

export const DOCUMENT = new InjectionToken<Document>('DocumentToken');

Inside @angular/platform-browser, however, we see it getting actual value (code snippet simplified):

{provide: DOCUMENT, useValue: document}

When you add BrowserModule to your app.browser.module.ts you register a bunch of different implementations for built-in tokens, such as RendererFactory2, Sanitizer, EventManager and our DOCUMENT. Why is it like that? Because Angular is multiplatform framework. Or rather platform agnostic. It works with abstractions and utilizes dependency injection mechanism heavily to be able to work in browser, on server or mobile platforms. To figure it out, let's take a sneak peak at ServerModule — another bundled platform (code snippet simplified):

{provide: DOCUMENT, useFactory: _document, deps: [Injector]},

// ...

function _document(injector: Injector) {
  const config = injector.get(INITIAL_CONFIG);
  const window = domino.createWindow(config.document, config.url);

  return window.document;
}

We see that it uses domino to create an imitation of a document, based on some config it once again got from the DI. This is what you get when you, for example, use Angular Universal for server side rendering. We already see the first and most important benefit. Using DOCUMENT token would work in SSR environment whereas accessing global document will not.

Other global entities

So Angular team took care of document for us which is nice. But what if we want to check our browser with userAgent string? To do so we would typically access navigator.userAgent. What this actually means is that we first reach for global object, window in browser environment, and then get its navigator property. So let's start by implementing WINDOW token. It is pretty easy to do with a factory that you can add to token declaration:

export const WINDOW = new InjectionToken<Window>(
  'An abstraction over global window object',
  {
    factory: () => inject(DOCUMENT).defaultView!
  },
);

This is enough to start using WINDOW token similar to DOCUMENT. Now we can use similar approach to create NAVIGATOR:

export const NAVIGATOR = new InjectionToken<Navigator>(
  'An abstraction over window.navigator object',
  {
    factory: () => inject(WINDOW).navigator,
  },
);
We will take it one step further and create a token for USER_AGENT the same way. Why? We'll see later!

Sometimes making a simple token is not enough. Angular's Location class is basically a wrapper over native location that improves our dev experience. Since we are used to RxJS streams let's replace requestAnimationFrame with an Observable implementation:

export const ANIMATION_FRAME = new InjectionToken<
  Observable<DOMHighResTimeStamp>
>(
  'Shared Observable based on `window.requestAnimationFrame`',
  {
    factory: () => {
      const performanceRef = inject(PERFORMANCE);

      return interval(0, animationFrameScheduler).pipe(
        map(() => performanceRef.now()),
        share(),
      );
    },
  },
);

We skipped PERFORMANCE token creation because it follows the same pattern. Now we have a single shared requestAnimationFrame based stream of timestamps which we can use across our app. After we replaced everything with tokens our components no longer rely on magically available items and get everything they depend on from dependency injection, which is neat.

Server Side Rendering

Now say in our app we want to do window.matchMedia('(prefers-color-scheme: dark)'). While there is certainly something in our WINDOW token on the server side, it definitely does not provide full API that Window object has. If we try the above method in SSR we would probably get undefined is not a function error. One thing we can do is wrap such cases with isPlatformBrowser checks but that's boring. Advantage of DI is we can override stuff. So instead of handling these situation as special cases we can provide WINDOW in our app.server.module.ts with a type-safe mock object that will protect us from non-existent properties.

This showcases another important advantage this approach has: token values can be replaced. This makes it very easy to test components that rely on browser API, especially if you test in Jest where native API is not available otherwise. But mocks are dull. Sometimes we can actually provide something meaningful. In SSR environment we have request object which contains user agent data. That's why we separated it into its own token — because we can actually obtain it separately sometimes. Here's how we can turn request into provider:

function provideUserAgent(req: Request): ValueProvider {
  return {
    provide: USER_AGENT,
    useValue: req.headers['user-agent'],
  };
}

And use it in our server.ts when we are setting up Angular Universal:

server.get('*', (req, res) => {
  res.render(indexHtml, {
    req,
    providers: [
      {provide: APP_BASE_HREF, useValue: req.baseUrl},
      provideUserAgent(req),
    ],
  });
});

Node.js also has its own implementation of Performance which we can use on the server side:

{
  provide: PERFORMANCE,
  useFactory: performanceFactory,
}

// ...

export function performanceFactory(): Performance {
  return require('perf_hooks').performance;
}

In case of requestAnimationFrame we will not need Performance though. We probably do not want our Observable chain to run on the server so we can provide EMPTY:

{ 
  provide: ANIMATION_FRAME,
  useValue: EMPTY,
}

We can follow this pattern to "tokenize" all global objects that we use across our app and provide replacements for various platforms we might run it on.

Wrapping up

With this approach your code becomes well abstracted. Even if you do not run it on the server now, you might want to do it later and you will be ready. Besides, it is much easier to test code when things it relies upon can be substituted. We have created a tiny library of common tokens that we use:

ng-web-apis/common
A set of common utils for consuming Web APIs with Angular - ng-web-apis/common

If you need something that is currently missing, feel free to create an issue! There is also a sidekick package with SSR versions of those tokens:

ng-web-apis/universal
A counterpart to common package to be used with Angular Universal - ng-web-apis/universal
You can explore this Rick and Morty themed project by Igor Katsuba using SSR to see all this in action. If you are interested in Angular Universal in particular, read his article about issues he faced and how to overcome them.

Thanks to this pattern our Angular components library Taiga UI was able to run on both Angular Universal and Ionic setups with no extra fuss. I hope it will be beneficial to you as well.