Catalin Ciubotaru avatar

Efficient Content Hydration with Angular Universal

Since the Server πŸ—„ is doing it, let's make sure the Browser 🌎 doesn't.

The Problem

With Angular Universal properly setup, we start having an issue. We have a relatively complex feature that let's say does this:

  • Fetches all movies the user tracked so far
  • Shows a pretty UI with all these movies
  • Allows the user to filter this list by year, genre, director and actor

This seems like a common scenario. The problem with it is that this work will be done twice: once on the Server and once on the Browser. This means double the amount of network requests and a flash of content when the Browser finishes wiring up Angular.

Good to know before jumping in

Angular has something called TransferState Service. Long story short, this is a service you can use to send data between the server and the client in a key-value way. If you want me to cover this in more depth, please let me know.

This examples also uses Akita Store, but any stream-service solution(NgRX, ServiceStore or a simple BehaviourSubject) would work. Think of it as a place to put data, and to react to data changes(I know, this is already getting complicated πŸ˜…).

Solution

There are three parts to the solution:

  1. When the Server is done putting data in the Store, put that data in the StateTransferService as well.
  2. On the Browser, if there's data in the StateTransferService, use that data to boot up the Store, instead of the default value.
  3. If there was data in the StateTransferService, do not emit after booting up the store, this is needed in order to avoid a flash of content.

Part 1 - Put data in the StateTransferService

For simplicity, let's assume you have a service called UserMoviesService, and this service has an aptly named method called setup(). This method is called from the Container Component and might look something like this:

setup(): void {
this.query.userSelection$
.pipe(
switchMap((selection) =>
this.http.get<MoviesResponse>(this.config.searchUrl, {params: selection})
),
tap((data) => this.mapAndAddDataToStore(data)),
tap(() => {
if (isPlatformServer(this.platformId)) {
const storeValue = this.store.getValue();
this.stateTransferService.setValue(
KeyForTheDataInTheStateTransferService,
storeValue
);
}
})
).subscribe();
}

Now, if this look scary, don't worry. Let's unpack πŸ“¦ it.

First, in the setup method we listen to the userSelection$ stream from the Store. This means that every time the user changes their selection(e.g. only wants to see the tracked movies older than 2010), this stream will emit a new value(a new selection). Based on the selection, we do a new HTTP call where we fetch the new data. That data is later manipulated and added to the Store(maybe combined with other streams).

The last tap is a key one. There, we check if the code is running on the Server. If it is, we get the Store value and put it in our StateTransferService. Hope it makes more sense now.

Part 2 - Boot up the Store with the correct value

The second piece of the puzzle happens when the Store is booted up. Here's an example implementation:

export interface SearchMoviesState {
results: MovieResult[];
selection: SearchSelection;
}

export function createInitialState(stateTransferService: StateTransferService): SearchMoviesState {
const defaultValue: SearchMoviesState = {
results: [],
selection: null,
};

if (stateTransferService.hasValue(KeyForDataInTheStateTransferService)) {
return stateTransferService.getValue(KeyForDataInTheStateTransferService);
}

return defaultValue;
}

@Injectable({
providedIn: SearchMoviesCoreModule
})
@StoreConfig({ name: 'search', resettable: true })
export class SearchMoviesStore extends Store<SearchMoviesState> {
constructor(@Optional() stateTransferService: StateTransferService) {
super(createInitialState(stateTransferService));
}
}

Yet another piece of code that looks complicated, but hopefully we can decipher it πŸ”‘. First, we define an interface for how the SearchMoviesState looks like. This represents the shape of data held by the Store. Afterwards, we define a method that returns the seed data for the Store. This is the key part. In here, we check if there's a value in the StateTransferService. If there is one, we use that, otherwise we use the default seed object. Lastly, the boilerplate part for creating the Store. The only difference is the fact that we need to inject the StateTransferService.

Just a bit more, we're almost there.

Part 3 - Avoid Flash of content for pre-rendered pages πŸ“Έ

We still have some flash of content though. This is because when the setup happens on the Browser, the stream will have an initial emission, which will trigger a page re-render because even though the objects have the same data, they are different objects(different references). Here's how we can get rid of that:

setup(): void {
this.query.userSelection$
.pipe(
switchMap((selection) => {
if(stateTransferService.hasValue(KeyForDataInTheStateTransferService)) {
this.stateTransferService.clearValue(KeyForTheSearchDataInTheStateTransferService);
return NEVER;
}
return this.http.get<MoviesResponse>(this.config.searchUrl, {params: selection});
}),
tap((data) => this.mapAndAddDataToStore(data)),
tap(() => {
if (isPlatformServer(this.platformId)) {
const storeValue = this.store.getValue();
this.stateTransferService.setValue(
KeyForTheDataInTheStateTransferService,
storeValue
);
}
})).subscribe();
}

It's almost the same code as on step 1, but we added an extra piece of logic. In the switchMap we now check if there's already a value in the Store and if there is, we clear it, and we return NEVER. This stops the stream basically. We're not going to do another network request, and we're not going to update the Store. With all of this in place, when the Container Component calls the setup method, the store will be initiated and wired up, but it won't emit a new value, so we get to keep the DOM rendered by the Server.

And this is how you get the best of both worlds: Server Side Rendered Speed + SPA functionality, without doubling the effort.

Want more?

If you want to know more about this, or something doesn't make any sense, please let me know.

Also, if you have questions, you know where to find me… On the internet!

Be kind to each other! 🧑

Over and out

My Twitter avatar
Catalin Ciubotaru πŸš€

Finally, I wrote a new blog post ✍️ => Efficient Content Hydration with Angular Universal https://catalincodes.com/posts/efficient-content-hydration-with-angular-universal #Angular #AngularUniversal #ServerSideRendering

Apr 30, 2022
00 people are talking about this

Wanna read more?

Updates delivered to your inbox!

A periodic update about my life, recent blog posts, how-tos, and discoveries.

No spam - unsubscribe at any time!