Learn how to effectively manage Angular Transfer State with server and static rendering using Universal.
What?
Angular’s TransferState
class enables server-side rendered (SSR) and prerendered (SSR) Angular applications to efficiently render in the browser using fetched data from the server.
Let’s break that down, starting first with the potential problem.
The problems
If you are using an SSR or prerendered application strategy then the process is roughly this:
- Prerender or render application on the server
- The browser fetches the rendered HTML and CSS and displays the “static” application
- The browser fetches, parses, interprets and executes JavaScript
- The Angular application is bootstrapped, replacing the entire DOM tree with the new “running” application
- The application is initialized, often fetching data from a remote server or API
- The user interacts with the application
There are two problems in this scenario:
- DOM hydration is currently replacing the entire tree of nodes and re-painting the application
- The application is fetching the data it, in theory, already had and was displayed to the user due to the SSR or prerendered site strategy
The first issue is something that is not currently solved in Angular, and quite frankly, is a complex challenge.
However, the second issue is currently solved in Angular (version 9 as of this writing) using TransferState
.
What is TransferState
?
Transfer state is a strategy where we:
- Fetch the data required to render the full “static” application using either the SSR or prerendering strategies
- Serialize the data, and send the data with the initial document (HTML) response
- Parse the serialized data at runtime when the application is initialized, avoiding a redundant fetch of the data
Technically, the data is serialized via JSON.stringify()
and parsed via JSON.parse()
.
Generally, we don’t need to worry about that, however, as that is performed for us by the TransferState
service in Angular.
Let’s quickly review the documentation:
The values in the store are serialized/deserialized using JSON.stringify/JSON.parse. So only boolean, number, string, null and non-class objects will be serialized and deserialzied in a non-lossy manner.
If your application requires transferring state from the server to the client that does not conform to these requirements then you will need to implement a strategy for converting the data, both to, and from, what I’m calling “compliant” data.
My specific use case is serializing objects from Firestore that contain DocumentReference
classes.
A document reference is a foreign key in Firestore, which is a specialized class that the Firebase SDK provides.
Goals
My goals for using transfer state are to:
- Utilize RxJS data streams to “intercept” fetched data using SSR or prerendering
- Implement a strategy for converting non-compliant data into compliant data for serialization, and also, in turn, converting serialized compliant data back to the original structure
The third goal is necessary for my use case since I need to convert Firestore’s DocumentReference
classes into something that can be serialized by TransferState
.
Getting Started
To get started with the TransferState
service we need to:
- Import the
ServerTransferStateModule
in our server application module. - Import the
BrowserTransferStateModule
in our browser application module.
When using server-side rendering you will often have a separate application entry point, most commonly in the form of a src/main.server.ts file.
And, the browser entry point is most commonly the src/main.ts file generated by the Angular CLI.
These files will then bootstrap the application via a NgModule
class.
So, let’s first open src/app/app.server.module.ts and import the ServerTransferStateModule
:
@NgModule({
imports: [AppModule, ServerModule, ServerTransferStateModule],
bootstrap: [AppComponent],
})
export class AppServerModule {}
Then, let’s import the BrowserTransferStateModule
into the src/app/app.module.ts file:
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule.withServerTransition({ appId: 'serverApp' }),
BrowserTransferStateModule,
CoreModule,
// code omitted
],
bootstrap: [AppComponent],
})
export class AppModule {}
TransferStateService
To solve the first goal of intercepting fetched data in RxJS streams I created a TransferStateService
that can be provided to Angular component classes that fetch data.
@Injectable({
providedIn: 'root',
})
export class TransferStateService {
/**
* The state keys.
*/
private keys = new Map<string, StateKey<string>>();
constructor(
@Inject(PLATFORM_ID) private readonly platformId,
private readonly transferState: TransferState,
) {}
fetch<T>(
key: string,
observableInput: Observable<T>,
defaultValue?: T,
converter?: TransferStateConverter<T>,
): Observable<T> {
if (this.has(key)) {
return of(this.get(key, defaultValue, converter)).pipe(
tap(() => this.remove(key)),
);
}
return observableInput.pipe(
tap((value) => this.set(key, value, converter)),
);
}
get<T>(
key: string,
defaultValue?: T | null,
converter?: TransferStateConverter<T>,
): T | null {
if (!this.has(key)) {
return defaultValue || null;
}
const value = this.transferState.get<T>(
this.getStateKey(key),
defaultValue,
);
return converter ? converter.fromTransferState(value) : value;
}
has(key: string): boolean {
return this.transferState.hasKey(this.getStateKey(key));
}
remove(key: string): void {
if (!this.has(key)) {
return;
}
this.transferState.remove(this.getStateKey(key));
}
set<T>(key: string, value: T, converter?: TransferStateConverter<T>): void {
if (isPlatformServer(this.platformId)) {
if (this.has(key)) {
console.warn(
`Setting existing value into TransferState using key: '${key}'`,
);
}
if (!environment.production) {
console.log(`Storing TransferState for: '${key}'`);
}
this.transferState.set(
this.getStateKey(key),
converter ? converter.toTransferState(value) : value,
);
}
}
private getStateKey(key: string): StateKey<string> {
if (this.keys.has(key)) {
return this.keys.get(key);
}
this.keys.set(key, makeStateKey(key));
return this.keys.get(key);
}
}
Let’s quickly review:
- First, we inject the
platformId
string andTransferState
class instance. We’ll use theplatformId
to check if we are executing the Angular application in the context of the server. If you’re using a browser-based prerendering strategy then you’ll need to adjust this to determine when the application is being executed in the context of prerendering. - Then, we’ve defined a
fetch
method that accepts akey
value that uniquely identifies the data, anobservableInput
, and optionally adefaultValue
andconverter
. We’ll talk more about the converter strategy below. - The
fetch
method “intercepts” thenext
notification and stores the value using theTransferState
service. Theset
method in the class handles this. - The
get
method returns the value from theTransferState
service if it exists, otherwise, it returns thedefaultValue
ornull
. It also handles converting data from the “compliant” serialized value back to the original value. - The
has
method returns aboolean
value indicating if the data exists in theTransferState
class. - The
remove
method removes the data from theTransferState
class. This is to ensure that subsequentnext
notifications in our RxJS observable streams on the client are not the stale data that was fetched on the server-side. - The
set
method stores the data into theTransferState
service, as well as converting non-compliant data into serializable values. - Finally, the private
getStateKey
method returns theStateKey
for thekey
string provided.
Here is a sample implementation of using the TransferStateService
in an Angular resolver:
export class RuleResolver implements Resolve<Rule> {
constructor(
private readonly rulesService: RulesService,
private readonly transferStateService: TransferStateService,
) {}
resolve(snapshot: ActivatedRouteSnapshot) {
return this.transferStateService.fetch<Rule>(
RULE,
this.rulesService.getBySlug(snapshot.paramMap.get('slug')),
);
}
}
Above we are using the fetch
method for the injected TransferStateService
instance to store the values fetched in the RuleService.getBySlug()
method.
TransferStateConverter
As I previously mentioned in my specific use case I have non-compliant values that need to be converted for serialization into the TransferState
service.
To solve the second goal I wanted to create a well-defined, and type-safe, implementation for converting data.
First, let’s define a new abstract TransferStateConverter
class:
import { Serializable } from '@lkt-core/types';
export abstract class TransferStateConverter<T> {
/**
* Called by the TransferStateService to convert a serialized value to an object of type T.
*/
abstract fromTransferState(data: Serializable<T>): T;
/**
* Called by the TransferStateService to convert data to a value that is serializable by TransferState.
*/
abstract toTransferState(data: T): Serializable<T>;
}
Two required methods must be implemented by converters:
- The
toTransferState
method accepts the non-compliantdata
and returns theSerializable
value. - The
fromTransferState
method accepts theSerializable
value and returns the non-compliant value.
The toTransferState
method will be executed in the context of the server or prerendering.
And, the fromTransferState
method will be executed in the context of the browser on the client.
You will also need to define a new Serializable
type:
export type Serializable<T> = boolean | number | string | null | object | T;
The Serializable
type represents values that can be serialized to JSON for use with the TransferState
service.
Finally, let’s look at a sample implementation.
We’ll stick with serializing a Rule
.
In my application, this is the object that is returned from Firestore that contains the non-compliant DocumentReference
class.
Here is what a portion of the model interface looks like:
export interface Rule {
// code omitted
related?: Array<DocumentReference>;
tags?: Array<DocumentReference>;
}
Note that the related
and tags
properties are an array of DocumentReference
classes that are provided by the Firebase SDK.
Let’s create a new RuleConverter
class that can be delegated to for converting the values, both from, and to, Serializable
data:
type SerializedRule = Rule & {
related?: string[];
tags?: string[];
};
@Injectable({
providedIn: 'root',
})
export class RuleConverter extends TransferStateConverter<Rule> {
constructor(
private readonly rulesService: RulesService,
private readonly tagsService: TagsService,
) {
super();
}
fromTransferState(rule: SerializedRule): Rule {
return {
...rule,
related: rule.related
? rule.related.map((id: string) => this.rulesService.getRef(id))
: [],
tags: rule.tags
? rule.tags.map((id: string) => this.tagsService.getRef(id))
: [],
};
}
toTransferState(rule: Rule): Serializable<Rule> {
return {
...rule,
related: rule.related ? rule.related.map((ref) => ref.id) : [],
tags: rule.tags ? rule.tags.map((ref) => ref.id) : [],
};
}
}
Let’s skip over some of my specific implementation details, and rather, let’s focus on the implementation of the TransferStateConverter
abstract class:
- First, we extend the
TransferStateConverter
class providing the generic type for the converter. In this case, this converter is for converting aRule
. - The
constructor()
function provides injected dependencies for my implementation. - The
fromTransferState()
method accepts therule
argument, which is aSerializedRule
and returns aRule
. In my implementation, I have converted a documentid
, which has been converted from theDocumentReference
to astring
using thegetRef()
method in my service. - The
toTransferState()
method accepts therule
argument, which is the non-compliantRule
object that contains theDocumentReference
classes. This method converts references to theid
string values.
We can now implement the converter into the resolver:
const RULE = 'rule';
export class RuleResolver implements Resolve<Rule> {
constructor(
private readonly ruleConverter: RulesConverter,
private readonly rulesService: RulesService,
private readonly transferStateService: TransferStateService,
) {}
resolve(snapshot: ActivatedRouteSnapshot) {
return this.transferStateService.fetch(
RULE,
this.rulesService.getBySlug(snapshot.paramMap.get('slug')),
null,
this.ruleConverter,
);
}
}
What I like about this delation pattern is that I provide the RuleConverter
class instance to the TransferStateService
to effectively convert values to, and from, serializable values.
Conclusion
I’ve achieved both of my stated goals by defining a TransferStateService
as well as a pattern for defining classes that extend the abstract TransferStateConverter
class.