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
ServerTransferStateModulein our server application module. - Import the
BrowserTransferStateModulein 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
platformIdstring andTransferStateclass instance. We’ll use theplatformIdto 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
fetchmethod that accepts akeyvalue that uniquely identifies the data, anobservableInput, and optionally adefaultValueandconverter. We’ll talk more about the converter strategy below. - The
fetchmethod “intercepts” thenextnotification and stores the value using theTransferStateservice. Thesetmethod in the class handles this. - The
getmethod returns the value from theTransferStateservice if it exists, otherwise, it returns thedefaultValueornull. It also handles converting data from the “compliant” serialized value back to the original value. - The
hasmethod returns abooleanvalue indicating if the data exists in theTransferStateclass. - The
removemethod removes the data from theTransferStateclass. This is to ensure that subsequentnextnotifications in our RxJS observable streams on the client are not the stale data that was fetched on the server-side. - The
setmethod stores the data into theTransferStateservice, as well as converting non-compliant data into serializable values. - Finally, the private
getStateKeymethod returns theStateKeyfor thekeystring 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
toTransferStatemethod accepts the non-compliantdataand returns theSerializablevalue. - The
fromTransferStatemethod accepts theSerializablevalue 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
TransferStateConverterclass 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 theruleargument, which is aSerializedRuleand returns aRule. In my implementation, I have converted a documentid, which has been converted from theDocumentReferenceto astringusing thegetRef()method in my service. - The
toTransferState()method accepts theruleargument, which is the non-compliantRuleobject that contains theDocumentReferenceclasses. This method converts references to theidstring 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.