A short while ago I wrote this post explaining how to properly access browser types - such as localStorage - from an Angular Universal web application using a situational approach built upon the PLATFORM_ID token and the isPlatformBrowser & isPlatformServer methods. Although that workaround might definitely point us in the right direction to write isomorphic JavaScript code, it won't automatically make us write good Angular code... unless we understand something else.
The first thing we need to consider is that accessing global variables within an Angular component is never the proper way of doing things, regardless of the executing context. As a matter of fact it's always a bad practice, as it goes against the Angular DI pattern, even if we do that when these variables are available.
To better understand that, let's pick the following code:
1 2 3 4 5 6 7 8 9 |
ngOnInit() { if (isPlatformBrowser(this.platformId)) { // Client-only code: use localStorage window.localStorage.addItem("someKey", "someValue"); } if (isPlatformServer(this.platformId)) { // Server-only code: do nothing } } |
Is this code isomorphic? Yes. Will it work fine in an Universal web app? Yes. Can it be considered a good piece of Angular code? No.
Writing isomorphic code and writing decent Angular code are two completely different things. In the above code we're not injecting anything that contains the localStorage object, we're just trying to access it directly as a global variable: as a general rule we could say that, when we're looking at an Angular component code, any global access means that there's something wrong. There are no exceptions here: it could work fine, it could get us where we want to, it could even be the only way to get the job done, yet it's still wrong for a number of reasons, including the following:
- It could cause issues with some TypeScript compiler, expecially if they are configured to perform strict type checks with no browser and/or generic types support.
- It will cause issues on some AoT compilers, including those who don't exist yet but we might choose (o be required) to adopt in future.
- It will not give us testable code, thus making it harder to understand what's going on.
What's the proper way to deal with these types then? If we want to stick to the Angular DI-based pattern, the best thing we can do is inject an adapter for such types that will work for both the server-side and client-side executing contexts with specific behaviours. That adapter can either be a function, a custom-made class or anything else, depending on our scenario. For the localStorage object a function returning an untyped option should be more than enough:
1 2 3 |
export function getLocalStorage() { return (typeof window !== "undefined") ? window.localStorage : null;; } |
We can then put this function in our AppModule file and then use it as a factory for a LOCALSTORAGE provider in the following way:
and then use it as a factory for a LOCALSTORAGE provider in the following way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppModuleShared } from './app.module.shared'; import { AppComponent } from './components/app/app.component'; @NgModule({ providers: [ { provide: 'LOCALSTORAGE', useFactory: getLocalStorage } ] }) export class AppModule { } export function getLocalStorage() { return (typeof window !== "undefined") ? window.localStorage : null; } |
If we have different AppModule files for the browser and for the server, we can safely use this function in the app.module.shared.ts file, unless we want to enforce completely different behaviours for the server and client contexts. If that's the case, it could be wiser to implement a custom class factory with a getValue() method that will conditionally operate and return something depending on the executing platform type, which you can determine checking the PLATFORM_ID token against the isPlatformBrowser method like already explained here.
Once done, we can inject the LOCALSTORAGE generic object in any of our Angular components and check for the platform type before using it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser, isPlatformServer } from '@angular/common'; @Injectable() export class SomeComponent { constructor( @Inject(PLATFORM_ID) private platformId: any, @Inject('LOCALSTORAGE') private localStorage: any) { // do something } NgOnInit() { if (isPlatformBrowser(this.platformId)) { // localStorage will be available: we can use it. } if (isPlatformServer(this.platformId)) { // localStorage will be null. } } } |
It's worth noting that, since our getLocalStorage() function will return null if the window object isn't available, we could just check for this.localStorage nullability and entirely skip the platform type check. However, the above one is the recommended approach: we can't take for granted that function return value, as its implementation might be subject to change in the future; conversely, the isPlatformBrowser / isPlatformServer return values are something that we can always trust by design.
That's it, at least for now: happy coding!
Great article. Very useful for JWT tokens.
How would you explain that such behaviour has not been anticipated by Angular Universal ?
It seems pretty obvious that one would need to save data in the browser using SSR.
Many thanks for the great article.