Conditional SSR templates made easy
If you have some experience with Angular and SSR you may already have an idea in your head to solve such an issue. However let's take a look at the traditional/naive way of solving an issue like this, just for the sake of being on common ground.
Following my thought process when solving programming issues in my day-to-day work, I'd take a step or two back and take a proper look at the big picture, the whole of the problem. In our example, the context of the problem is an Angular application where we'd like to display or hide some parts of the UI based on some logic. That sounds like we'd want to use the *ngIf
built-in directive from Angular and supply it with our logic to determine if our application runs on the server (SSR) or in the browser.
So far so good. But how are we going to "know" this information? Well, there are multiple ways to get this information but the most basic way is to ask Angular for it. Let's see how we'd do that.
import { Component, Inject, `PLATFORM_ID` } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component{
selector: 'spp-root',
styleSheetsUrls: ['./app.component.scss'],
templateUrl: '/.app.component.html',
})
export class AppComponent {
isBrowser = isPlatformBrowser(this.platformId);
isServer = isPlatformServer(this.platformId);
constructor(@Inject(`PLATFORM_ID`) public platformId: string) {}
}
- In our component .ts file we have to ask Angular for the most crucial piece of information, the active platform's identifier.
- We have to use the @Inject decorator to inject the
PLATFORM_ID
token, which holds the value of the active platform on which our application runs. - Then we need some utilities from Angular to interpret the platform identifier value we've received. Here we used the "isPlatformBrowser" and "isPlatformServer" functions exposed by Angular to get a boolean value indicating whether or not the app is running in the browser or on their server.
Now you might be wondering: What is a platform in the context of an Angular application? 🤔
I'm glad you've asked! 🤓
You see there are a lot of smart folks over at big daddy Google, so no wonder some of them had the foresight to design a software platform that's compatible with different execution environments. Now Angular certainly isn't the first and only software system designed with this foresight. Just think about Flutter for example. Point is that although there is an intended place for the system to be used, like in the browser to power SPAs, the system is equipped with the appropriate tools to work in other places too. In our case, two such execution environments are the browser and the server, most commonly a Node.js script. So in the context of an Angular application, a platform refers to a distinct execution environment.
Armed with this knowledge then let's take a look at our app.component's html:
<3rd-party-photo-gallery></3rd-party-photo-gallery>
- We have only one component used in our example to keep things simple.
- There's an imaginary 3rd party angular lib used here that exposes some kind of photo gallery, which unfortunately reaches into the DOM directly.
With all the setup done in our app.component.ts file previously we can just put an +ngIF directive onto this tag and pass it the "isBrowser" property of our component, like so:
<3rd-party-photo-gallery +ngIf="isBrowser"></3rd-party-photo-gallery>
Now that certainly solves our problem. However we don't just want to solve it, we'd want to solve it in a scalable way. Currently, however, our solution requires a setup logic in each component where we'd want to use it. That doesn't look too DRY, does it?
So next let's take some time and think about how we could extract this setup logic from our component and make it easily reusable. Right off the bat, there are a few ideas that should pop into your head. At least one of the following:
- Make an @Injectable service that exposes this logic to whatever component needs it.
- Create a mixin exposing this logic and extend whatever component needs this info with the mixin.
- Create a structural directive similar to Angular's +ngIf that contains all the setup logic inside itself.
I personally like all three ideas. So let's explore them one by one briefly.
InjectableService solution
This idea might be the most obvious for Angular devs. Whenever you have some reusable logic extract it into a service, then inject that service where ever it's needed.
There's certainly merit to this idea. We'd adhere to the SRP principle, with a good choice for our service's name it would be easy to find it in any code base. And overall a clean architectural solution.
There are some problems with it though. we may want to use smart/dumb component patterns in our application. That's reasonable to assume for almost any medium-sized or bigger Angular application. In that case, we'd surely run into situations where a dumb component required the logic provided by our service. Abiding by the dumb component rules would force us to obtain the platform-related information in some parent component and pass it down to its dumb child. Even worse is the possibility of multiple levels of dumb components in the component hierarchy. This is called data or input drilling, and we'd want to avoid it if possible. So while this solution feels to most natural to us, we might end up shooting ourselves into the foot with it down the line.
Mixin solution
In case you haven't used JS mixins before let me introduce them to you. A mixin is nothing more than a function that takes a class expression as an argument and returns a new anonymous class expression extending the received one. Thereby allowing us JS devs to use multiple inheritances in our codebase. Just for the sake of being precise, mixins don't really allow multiple inheritances in JS, because there's no such thing as multiple inheritances in the language. What we can achieve with mixins is more like a dynamic way to build the prototype chain of a JS class. Which is the closest we get to proper multiple inheritances, and is very similar to that in terms of how it works and what it can do.
So after this short introduction to mixins, let's see what a mixin-based implementation of our platform observing setup logic would look like:
import { Inject, `PLATFORM_ID` } from "@angular/core";
import { isPlatformBrowser, isPlatformServer } from "@angular/common";
export function platformObserverMixin<T extends Constructor<any>>(
Base: T = class {} as any
) {
isBrowser = isPlatformBrowser(this.platformId);
isServer = isPlatformServer(this.platformId);
class Mixin extends Base {
constructor(...args: any[]) {
super(...args);
}
}
return Mixin;
}
- As we outlined above we define a function that returns a class expression.
- we define a constructor for our Mixin class expression that receives all the arguments and passes them forward to its Base class.
- In our Mixin class expression, we define the two utility properties indicating whether the app runs on a given platform.
All right, looks fine, but how do we get a hold of the PLATFORM_ID
injection Token's value here?
I intentionally left out that part of the mixin because there are multiple ways to do so.
How to obtain external values inside of a mixin
Let's begin exploring these possibilities by establishing that a mixin can't and shouldn't know where it is used. Neither its child nor its base classes. We can't write code inside a mixin that makes assumptions on any of these. This may sound obvious and even more so unrelated to our current problem. But think about it. We are essentially writing code into a box whose surrounding we know nothing of. How could we then get something into the box from outside of it? Namely the PLATFORM_ID
InjectionToken's value or at least the means to obtain it ourselves inside the mixin?
While reading this last paragraph you might have figured out what alternatives I'm going to show you.
First of all, let's address the elephant in the room. We want to obtain a value from the Angular DI system that is a string. Because it is a string we have to use the @Inject decorator or the Angular Injector. Furthermore, we couldn't find the token's value with an array.find on the received arguments.
So what we can do is either wrap the token's value into something we can find and look for that value in the arguments.
That's where mixin config classes come into play.
import { Inject, `PLATFORM_ID` } from "@angular/core";
import { isPlatformBrowser, isPlatformServer } from "@angular/common";
export class PlatformObserverMixinConfig {
constructor(public platformId: tring) {}
}
export function platformObserverMixin<T extends Constructor<any>>(
Base: T = class {} as any
) {
class Mixin extends Base {
/** ... */
platformId: string;
constructor(...args: any[]) {
super(...args);
this.platformId = args.find(
(arg) => arg instanceof PlatformObserverMixinConfig
)?.platformId;
}
}
return Mixin;
}
Or alternatively, you can obtain the token's value inside the mixin. To achieve this we have to expose the Angular Injector for our mixin. The idea here is to obtain the Injector in the child class where the mixin is applied and pass the injector itself to the mixin via the constructor. There you can use the same args.find technique but this time to find the injector. Once you have it, then you can use it's .get method with the PLATFORM_ID
token.
import { Inject, Injector, `PLATFORM_ID` } from "@angular/core";
import { isPlatformBrowser, isPlatformServer } from "@angular/common";
export class PlatformObserverMixinConfig {
constructor(public platformId: string) {}
}
export function platformObserverMixin<T extends Constructor<any>>(
Base: T = class {} as any
) {
class Mixin extends Base {
/** ... */
platformId: string;
constructor(...args: any[]) {
super(...args);
this.platformId = args.find(
(arg) => arg instanceof Injector
)?.get(`PLATFORM_ID`);
}
}
return Mixin;
}
Both of these techniques achieve the same thing, and they are really similar too, so whichever you use is up to your choice.
Now that we have a mixin exposing the platform-related information for our components we only have to use this mixin on whatever components need it like so:
import { Component, Inject, `PLATFORM_ID` } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component{
selector: 'spp-root',
styleSheetsUrls: ['./app.component.scss'],
templateUrl: '/.app.component.html',
})
export class AppComponent extends platformObserverMixin() {
/** ... */constructor(injector: Injector) {
super(injector);
}
}
This implementation is also quite well thought out and could be widely used. But unfortunately, the criticism of the first idea applies here too. Dumb components would still have to touch the Angular DI system in some way to be able to supply the data needed by this mixin. Not to mention that neither this nor the service solution would make a nice and reusable solution in our .html files. Sure one more +ngIf isn't the end of the world, but it's just one more than what we'd like to add to our UIs.
So in order to try and fix this problem let's take a look at the 3rd option outlined previously.
Structural directive solution
So far we've tried to achieve to desired behavior by placing the controlling logic outside of the UI of our app and applying it to the necessary parts. When in fact we could just invert the flow of control and let UI decide what it wants to show.
Both previous solutions boiled down to getting the isPlatform boolean value and passing it to an *ngIf
. What if we had an *ngIf
that knew how to obtain the isPlatform boolean value by itself?
Enter *ngRenderIn
directive
import {
Directive,
EmbeddedViewRef,
Input,
OnDestroy,
OnInit,
TemplateRef,
ViewContainerRef,
} from "@angular/core";
import { EApplicationPlatform } from "../enums";
import { PlatformObserverService } from "../services";
@Directive({
selector: "[ngRenderIn]",
})
export class NgRenderInDirective implements OnInit, OnDestroy {
@Input("ngRenderIn") public platform!: EApplicationPlatform;
@Input("ngRenderInElse") public alternativeTemplate?: TemplateRef<unknown>;
protected _embeddedView!: EmbeddedViewRef<unknown>;
constructor(
protected readonly vcr: ViewContainerRef,
protected readonly templateRef: TemplateRef<unknown>,
protected readonly platformObserver: PlatformObserverService
) {}
public ngOnInit(): void {
if (this.platformObserver.platformID === this.platform) {
this._embeddedView = this.vcr.createEmbeddedView(this.templateRef);
} else if (this.alternativeTemplate) {
this._embeddedView = this.vcr.createEmbeddedView(
this.alternativeTemplate
);
}
}
public ngOnDestroy(): void {
if (!this._embeddedView.destroyed) {
this._embeddedView.destroy();
}
}
}
So what's going on here?
- We create a directive that injects the ViewContainerRef and TemplateRef providers to fulfill the basics
*ngIf
functionality. - We have two inputs:
- One to specify in which platform we want to render the directive's template
- And another one to optionally render a different template. Just to align our directive's usage with the built-in
*ngIf
directive. - We use the directive's ngOnInit lifecycle hook to do the platform check and display the directive's default template if the condition is true or the alternative template if there's one.
- We also implement the ngOnDestroy hook to clean the rendered template from the DOM when the directive's destroyed.
A few additional things of note. You may wonder where's the logic from the directive to obtain the platform details. It's inside the PlatformObserverService
which is injected in the directive's constructor. It's almost identical to what we saw in the @injectable solution so I'm not going to show it again here. Also, we have a helper enum EApplicationPlatform
used as the type of the main @Input of the directive. This aims to ease the usage when of the directive. And contains the available platform values, that you'd expect.
Let's see how it'd be used in our UIs:
import { Component, Inject, `PLATFORM_ID` } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
@Component{
selector: 'app-root',
styleSheetsUrls: ['./app.component.scss'],
templateUrl: '/.app.component.html',
})
export class AppComponent extends platformObserverMixin() {
EApplicationPlatform = EApplicationPlatform;
/** ... */
}
<3rd-party-photo-gallery
*ngRenderIn="EApplicationPlatform.Browser">
</3rd-party-photo-gallery>
This looks quite clean if you ask me.
We adhere to SRP, the participating classes and files didn't become cluttered and the actual logic is right where it has to be in the component .html. Not to mention it's well readable thanks to the choice of directive's name.
Ah yes, but there's still one more thing we could improve on. Do you see what I refer to?
Why do we exactly have to add even this one line of enum property definition in our component .ts file? Wouldn't it be nice if could get rid of even this boilerplate? Good news! We can.
Directive sub-classing
The *ngRenderIn
directive shown above requires instruction from outside about which platform it should render its content in. What if we applied the inversion of control one more time and give all the control to the directive?
For this, we have to make the directive aware of the instruction of the target platform. In fact, it wouldn't just be aware of it. It would be the one that gives the instruction. Fine fine, but how one directive's going to hold more than one instruction, and how the consuming component going to control it? It won't! Remember? We are inverting the flow of control.
The directive is the instruction itself so we need more than one directive to have multiple choices of instructions.
import { Directive, Input, TemplateRef } from "@angular/core";
import { EApplicationPlatform } from "../enums";
@Directive({
selector: "[ngRenderInBrowser]",
})
export class NgRenderInBrowserDirective extends NgRenderInDirective {
@Input("ngRenderIn")public override readonly platform = EApplicationPlatform.Browser;
@Input("ngRenderInBrowserElse")public override alternativeTemplate?: TemplateRef<unknown>;
}
@Directive({
selector: "[ngRenderInServer]",
})
export class NgRenderInServerDirective extends NgRenderInDirective {
@Input("ngRenderIn")public override readonly platform = EApplicationPlatform.Server;
@Input("ngRenderInServerElse")public override alternativeTemplate?: TemplateRef<unknown>;
}
Now we have one directive for each instruction of platform choice: browser and server.
Both directives extend *ngRenderIn
directive, so their implementations can be very lean.
They each do two main things: Supply a default value for the *ngRenderIn
input of the base directive class, thereby defining their own instruction. And redefine their alternativeTemplate Input's binding name to their own selector name. This way you can supply an else template for both of them using the Angular micro syntax like so:
<3rd-party-photo-gallery
*ngRenderInBrowser="else ServerTpl">
</3rd-party-photo-gallery>
<ng-template #ServerTpl>
This is rendered in SSR mode
</ng-template>
Wrapping up
It's been a long journey up until here, but we managed to cut down on UI complexity and the amount of code we have to write to be able to define different templates for SSR and browser modes. As you saw there are multiple options available to solve this issue, however as I've implied before I do think that this final one is the most robust and elegant if I can say so myself. Now, after all this, you might be thinking to yourself: Alright, pretty neat. But how could I use this solution myself?
Indeed, there have been numerous code examples throughout this article but it'd be an unpleasant task to extract them all into your own codebase. Lucky for you then, we've already done the heavy lifting for you. Me and my team over at @Adroit Group have been working on a utility library for Angular full of useful stuff. Including, of course, the **ngRenderIn
directive I've introduced to you today.
So if this directive or the library, which we'll certainly write more about soon, has sparked your interest, you can head on over to NPM or Github to take a look or even download and use it. 📚👀
I'd like to thank you for your time and attention in reading this article.
I was your tour guide Jonatán Ferenczfi, Frontend tech lead @Adroit Group, Angular bro, and practicing coffee addict. ☕
Until next time 👋