Angular selective partial client hydration

Just like React's suspense but Angular flavored

Jose I Santa Cruz G
ITNEXT

--

This is not the hydration you're thinking of…

On February 3 & 4, 2023 I attended to a Javascript conference at my country: the JsConf Chile (you can read my honest review article in Spanish for this conference here).

I’ve become an Angular and Typescript guy, so I had to mentally translate most of the presentations to a Typescript context, and think on how all new knowledge could be applied to the projects I was working on.

Incredible talks!, but there was one that particularly caught my attention: Streaming Server Rendering by Shaundai Person , about improving the performance and the UI/UX using React's suspense. Can this be applied in a certain way on Angular? Let's see…

By the way, as most of my articles end up being an extremely long and boring read, I'll try using a menu, so you can skip your reading to the good parts (anyway I do hope you read the whole article 😅):

  1. Loading non-lazy Suspenseable components
  2. Loading Suspenseable lazy-components declared in modules
  3. Loading a Suspenseable lazy-component that triggers an event when ready
  4. Loading a Suspenseable lazy-component that sets a flag when ready

Serve the children first

A toddler being fed at a reataurant
"table'd toddler": serve the children first

If you have children and have gone to a restaurant, you already know how to deal with your hungry kid's mood. So in order to make your app (your meal) "work" without issues, it can be a good idea to "make them happy" getting everything that can be required on a first run.

Thinking only on a Client Side Rendering (CSR) scenario, besides every strategy such as using application shells, standalone components, or lazy loading, it can be a good decision to load what is needed on the time it is really needed.
This is what the Islands Architecture, created by Katie Sylor-Miller, the front-end architect for Etsy, proposes. Components are "hydrated" (made available in the DOM, for interaction) once they are ready and once they are required. Even though this architecture is mostly used in Server Side Rendering, aka SSR (think Angular Universal), we can apply it on the client side.

Partial hydration using Suspense

Borrowing React's definition for Suspense:

<Suspense> lets you display a fallback until its children have finished loading.

OK! but that's for React, what about Angular? Angular does not have a Suspense component, so we'll have to build it using some tools we already have:

  1. Angular dynamic components: Components inside our suspense component don't have to be loaded at once.
  2. Javascript IntersectionObserver API: In our case, "when needed" will mean "when the component is visible on the screen" and we can control the component loading using this API.
  3. Webpack magic comments: We'll use them to have "nicely named" component bundles and to assign prefetch priorities.

I'm cheating more than a bit, as Netanel Basal already developed an Angular version for Suspense and has a nice article regarding it (please read it as it will explain many things), and took it as a codebase for developing QP Suspense.

QP Suspense specific use cases

QP Suspense gains the most advantage when loading lazy-components, that is a component that's not declared inside a module (including SCAMs). Don't really know if this is a sad restriction or not, because it gives an additional level of control over our components, but all components thought to be loaded using QP Suspense require extending certain Suspenseable typed classes, besides some other specific code.

Almost forgot, QP Suspense has a nice demo app that shows how to use it. It includes examples for all use cases. Just clone the repo from https://github.com/QuePlan/qp-suspense , install all dependencies, and run:

npm start -- --configuration=development --project qp-suspense-demo --o

A thing that may disturb you as a developer (unless you're confortable with Spanish or you don't really mind the language, as the code speaks by itself) is that the library was developed thinking in a Spanish speaking developer audience, so all comments are in Spanish (don't kill the messenger for this).
Nevertheless, Google translate does a pretty good job translating all QP Suspense documentation to English.

Loading non-lazy Suspenseable components

In the HTML template:

<suspense style="display: flex; flex-direction: column; min-height: 6.5rem;">
<app-not-lazy saludo="Hello there!"></app-not-lazy>
<app-not-lazy2 saludos="Hello to everyone!"></app-not-lazy2>

<ng-template fallbackView>
<span>Loading... Please wait.</span>
</ng-template>
</suspense>

fallbackView is the placeholder for the loader/spinner shown while the component is not ready yet.

And, for eg, the app-not-lazy component would be something like:

import { Component, Input } from '@angular/core';
import { tap, timer } from 'rxjs';
import { useSuspense, SuspenseableClassic } from '@queplan/qp-suspense/types';

@Component({
selector: 'app-not-lazy',
templateUrl: './not-lazy.component.html',
styleUrls: ['./not-lazy.component.scss'],
providers: [useSuspense(NotLazyComponent)],
})
export class NotLazyComponent extends SuspenseableClassic {
@Input() saludo: string;
ngOnInit(): void {}
ngOnDestroy(): void {}
setup() {
return timer(3000).pipe(
tap(() => console.log('NotLazyComponent 3000'))
);
}
}

There're 3 considerations in this code:

  1. Non-lazy suspenseable components MUST have the useSuspense(<component>) provider, otherwise it won't be able to "know" if it has to be loaded using suspense or not.
  2. Non-lazy suspenseable components MUST extend SuspenseableClassic class so the component is enabled to be loaded using suspense.
  3. Non-lazy suspenseable components MUST implement the setup() function returning an Observable. SuspenseableClassic components are "ready to be displayed" when the setup() function is done.

Loading Suspenseable lazy-components declared in modules

The lazy CatComponent is "declared" inside CatModule (I know… a box is not a module, but tell that to the AI…)

When a component is declared into a module, at first you don't know what kind of Suspenseable component it is. So if your Suspenseable component is declared inside a module be sure to make it extend from SuspenseableInModule.

export abstract class SuspenseableInModule extends Suspenseable {}

It's just developer-candy to internally extend from Suspenseable (no one will know 😉). And in the insides you'll have to implement whatever you need; and if you don't you'll get some nice error message such as:

‘setup() no implementado’ (which is setup() not implemented in Spanish)

All the magic happens inside the getComponentInstance function:

  /**
* Creates a Suspenseable component instance, and if required
* set @Input() parameters.
* The instance will be created from the component class or from the module where
* the component is defined.
* @param clazz Component class
* @param componentParams @Input() parameters for the component
* @param isModule true if the component is defined inside a module
* @param clazzName (optional) Component classname
* @returns Component instance
*/
private getComponentInstance(clazz: TDefaultSuspenseable | Type<unknown>, componentParams?: { clazzName?: string, [key: string]: unknown }, isModule: boolean | string = false, clazzName?: string): ComponentRef<ISuspenseable> {
let componentInstance: ComponentRef<ISuspenseable>;

let compClazz : Type<ISuspenseable>;
let isStandAlone = false;
if (isModule) {
suspenseConsole.log('Component is inside a module: ', clazz, Object.keys(clazz));
const moduleName: string = (typeof isModule === 'string') ? isModule : Object.keys(clazz).shift() as string;
const moduleRef: NgModuleRef<SuspenseableModule> = createNgModule(clazz[moduleName as keyof typeof clazz], this.injector)
compClazz = moduleRef.instance.getComponent();
} else {
suspenseConsole.log('This is a Suspenseable component: ', clazz);
let _clazzName: string | undefined = !clazzName ? ( !(clazz as TDefaultSuspenseable).default ? Object.keys(clazz).shift() as string : undefined ) : clazzName;
compClazz = !_clazzName ? (clazz as TDefaultSuspenseable).default : (clazz as TDefaultSuspenseable)[_clazzName as keyof typeof clazz];
isStandAlone = !!_clazzName;
}

/**
* **IMPORTANT**: When creating the component instance, if this is done using `this.anchor.createComponent(compClazz)`, the component instance is
* IMMEDIATLY rendered, disregarding if it's ready to be displayed or not..
* CReating a component with `createComponent()` does not render the component, but doesn't work for `standalone` components.
*/
componentInstance = isStandAlone ? this.anchor.createComponent(compClazz) : createComponent(compClazz, { environmentInjector: this.environmentInjector });
// componentInstance = createComponent(compClazz, { environmentInjector: this.environmentInjector });
this.setComponentParams(componentInstance.instance, componentParams);

return componentInstance;
}

The component and module (ala SCAM, Single Component as Module):

import { NgModule, Type, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ObservableInput, of } from 'rxjs';
import { ISuspenseable, SuspenseableInModule, SuspenseableModule } from '@queplan/qp-suspense/types';

@Component({
selector: 'app-lazy-in-module',
templateUrl: './lazy-in-module.component.html',
styleUrls: ['./lazy-in-module.component.css'],
})
export class LazyInModuleComponent extends SuspenseableInModule {

@Input() whoAmI: string;

ngOnInit(): void {}
ngOnDestroy(): void {}

setup(): ObservableInput<any> {
return of({ whoAmI: this.whoAmI });
}
}

@NgModule({
imports: [CommonModule],
declarations: [LazyInModuleComponent],
exports: [LazyInModuleComponent],
})
export class LazyInModuleComponentModule implements SuspenseableModule {
getComponent(): Type<ISuspenseable> {
return LazyInModuleComponent;
}
}

the getComponent function HAS to be implemented; and the template (nothing fancy, just to see it work):

<p>lazy-in-module works!<br>
{{whoAmI}}</p>

To use it (in for eg a home.component):

// ... lots of other code
lazyInModuleFactory = () => import('../../lazy-in-module/lazy-in-module.module');
lazyInModuleParams = { clazzName: 'LazyInModuleComponent', whoAmI: 'I am your father!!!!' };

// ... etc

and the HomeComponent's template:

<suspense style="display: block; min-height: 3rem;">
<ng-template [defaultView]="lazyInModuleFactory" [componentParams]="lazyInModuleParams" [isModule]="true"></ng-template>
<ng-template fallbackView>
<p>Loading lazy component from module</p>
</ng-template>
<ng-template errorView>
<p>ERROR loading lazy component from module</p>
</ng-template>
</suspense>

Important things here:

  • defaultView attribute: is the factory function with the lazy loaded component import (in this case, from a module).
  • componentParams attribute: a JSON object with all the required parameters for this lazy loaded component.
  • isModule attribute in true, to tell QP Suspense this component is declared inside a module.

Nice to have, but not strictly necessary (unless you were insanely creative naming your component), you can add a clazzName attribute with the component's className just to skip an if inside the code.

Loading a Suspenseable lazy-component that triggers an event when ready

"Everything's ready to trigger some event", let's celebrate

Suspenseable components may not be truly aware of their loading status (when they are ready to be rendered), so waiting for the setup() function to be executed may not be an option. This may be the case of a component that, for eg, should be displayed after some API responds, and also show loading errors (if the happen). This is what I called "the reactive mode" ('cause it reacts to events 😄).

Lets suppose a footer component that should load lazily, display a list of footer links retrieves from an API, and in case of error show a simplified footer version.

The footer component itself would be something like:

import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SuspenseableBroadcaster } from '@queplan/qp-suspense';
import { FooterService } from '../../services/footer.service';
import { catchError } from 'rxjs';

@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule],
// Trust me, the template is not relevant to understand the example
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent extends SuspenseableBroadcaster implements OnInit {
footerService: FooterService = inject(FooterService);
footerLinks = [];

ngOnInit(): void {
this.footerService.retrieveFooterLinks().pipe(
catchError(error => {
console.error('ERROR retrieving footer links, applying fallback', error);
this.broadcastError();
return [];
})
).subscribe(
(footerLinks: Array<unknown>) => {
this.footerLinks = footerLinks;
this.broadcastLoad();
}
);
}

eventHandler(eventName: string): void {
this.eventName = eventName;
}

constructor() {
super();
}
}

4 important things here:

  1. The component MUST extend SuspenseableBroadcaster.
  2. Function eventHandler in the component MUST set the eventName variable. Otherwise reactive mode won't work.
  3. The component's constructor MUST call super() as the first instruction. This is required to display the component ONLY when it is ready to be displayed.
  4. When ready call broadcastLoad
    If error call broadcastError

The footer wrapper/container component should have some code similar to:

// ... some code before
footerFactory: SuspenseFactoryPromise = <SuspenseFactoryPromise>(() => import(
'./components/footer/footer.component') as unknown);
// ... some more code after

and the template would be something like:

<suspense>
<ng-template [defaultView]="footerFactory" onEvent="footer"></ng-template>
<ng-template fallbackView><qp-suspense-spinner>Loading Footer links...</qp-suspense-spinner></ng-template>
<ng-template errorView><app-simple-footer /></ng-template>
</suspense>

The onEvent attribute is required in order to tell QP Suspense what events it has to listen, based on the eventName. Once the event is received it will show the lazy loaded component or the error.

Event handling is basically a pub-sub strategy, done by using a service I've been recycling in more than a few projects: https://github.com/QuePlan/qp-suspense/blob/master/libs/suspense/services/event.service.ts

Internally QP Suspense broadcasts a 'load' or 'error' event :

broadcastLoad(): void {
/**
* If eventName !== undefined it means it's most likely a SuspenseableBroadcaster suspense component
*/
if (this.eventName) {
this.renderComponenteReady();
this.eventService.broadcast(`${this.eventName}:load`, true);
} else {
throw new Error('Using reactive mode without naming your event. Check eventHandler() implementation');
}
}

broadcastError(): void {
if (this.eventName) {
this.renderComponenteReady();
this.eventService.broadcast(`${this.eventName}:error`, true);
} else {
throw new Error('Using reactive mode without naming your event. Check eventHandler() implementation');
}
}

Errors are thrown to make sure developers don't miss any required parameters.

Yes, I know… "So much work to display a simple component".
What can I say besides "Yes 😬"

Loading a Suspenseable lazy-component that sets a flag when ready

Don't yawn 😪 while waiting for the component to be ready (you must yawn now 😝)

These kind of components extend from SuspenseableEventDriven which is a type of Suspenseable that implements the defaultEventDrivenSetup() function:

 /**
* Implements the `setup()` function in a way that allows a "better" control over the component's loading states.
* The idea is setting the setupReady or hasError variables inside the component so it becomes aware of it's status.
* @param response { [key: string]: unknown } JSON object with unknown structure, which will get the response.
* @param useInit { boolean } Should call `init()` before resolving `setup()`
* @returns { ObservableInput<any> } Observable, same as `setup()`
*/
defaultEventDrivenSetup(response: { [key: string]: unknown }, useInit = false): ObservableInput<any> {
suspenseConsole.log('[defaultEventDrivenSetup] setup()');

if(useInit) {
this.init();
}
/**
* Initial susbscription to setupReady.
* Will "listen" to the observable while it isn't ready OR it doesn't report an error, that's why it is combined.
* Filter to be aware of both answers, throws 'new Error()' on error.
* Responde un observable con el objeto de respuesta indicado.
*/
return this.setupReady.pipe(
takeUntil(
combineLatest([this.setupReady, this.hasError]).pipe(
filter(([ isReady, hasError ]) => isReady || hasError),
tap(
([ isReady, hasError ]) => {
suspenseConsole.log('[defaultEventDrivenSetup] isReady, hasError: ', isReady, hasError );
if (hasError) throw new Error('[defaultEventDrivenSetup] No se pudo cargar el componente');
}
)
)
),
map(() => (response))
);
}

This function uses a couple of BehaviorSubject flags:

  1. setupReady: set to true when the setup() should answer "I'm ready".
  2. hasError: set to true when the setup() fails and an error message should be thrown.

There may be some components that, in their original non-suspenseable form, do know when they are ready or have triggered an error.
For many of us, that's a little easier than building a whole setup() function.

The HTML template for these kind of components would be something like:

<suspense style="display: block; min-height: 3rem;">
<app-not-lazy-eventdriven eventNameRef="evtReference"></app-not-lazy-eventdriven>
<ng-template fallbackView>
<p>Loading this event-driven component (not lazy)</p>
</ng-template>
<ng-template errorView>
<p>ERROR loading this event-driven component (not lazy)</p>
</ng-template>
</suspense>

and the component:

import { Component, Input } from '@angular/core';
import { ObservableInput, timer } from 'rxjs';
import { SuspenseableEventDriven, useSuspense } from '@queplan/qp-suspense/types';
@Component({
selector: 'app-not-lazy-eventdriven',
templateUrl: './not-lazy-eventdriven.component.html',
styleUrls: ['./not-lazy-eventdriven.component.css'],
// not a lazy component so it requires the provider
providers: [ useSuspense(NotLazyEventdrivenComponent) ],
})
export class NotLazyEventdrivenComponent extends SuspenseableEventDriven {
@Input() eventNameRef: string; // just for the eg
init() {
if (this.initialized) {
console.log('[NotLazyEventdrivenComponent] Component already initalized (no need to do this again)');
return;
}

console.log('[NotLazyEventdrivenComponent] Initializing...');
this.initialized = true;
timer(3500).subscribe(() => {
console.log('[NotLazyEventdrivenComponent] Ready to be rendered after 3.5s');
this.setupReady.next(true);
// this.hasError.next(true);
})
}
setup(): ObservableInput<any> {
const response = { eventName: this.eventNameRef };
// When ready return the response object, use the init function
return this.defaultEventDrivenSetup(response, true);
}

ngOnInit(): void {
console.log('[NotLazyEventdrivenComponent] ngOnInit()');
this.init();
}
ngOnDestroy(): void { }


}

A nice thing in this library is that it's thought to be developer friendly. If you miss a parameter, or mistype a variable name, the console will show an error message supposedly clear enough to solve it.

Suspenseable Islands (loading components when required)

It's hard to get creative with such an abstract concept

QP Suspense provides a really nice way of lazy loading components outside the routing context (the lazy modules we all love). But what about doing this lazy load when it is REALLY required? And by REALLY required I mean when the component is about to get in the viewable screen of our app.

Javascript provides an API exactly for this: the IntersectionObserver API.

The idea behind this is that when scrolling, all (or most) components next to be rendered on our screen will load, and this will happen only when they are next to be rendered, not before. The difference with a traditional Angular app, is that on a first load of your "suspensed app", your app will load only those scripts required for rendering the components visible on the screen, and perhaps (depends on the configuration) those thought to be rendered, for eg, when scrolling 1 page down.

Googling a bit, you'll find lots of implementations for an Angular Intersection Observer library, but the code from this article, IMHO, is one of the best you can find (just ignore that the demo app is not actually working 🤷‍♂️).

Another library I've found to be quite useful, not just for wrapping the Intersection Observer API ala Angular, is NG Web APIs.

On an example using our previous footer component:

<div intersectionObserver [intersectionThreshold]="0.1" [intersectionRootMargin]="'0px 0px 200%'" (visibilityChange)="makeFooterVisible($event, 'footer')" style="display: block; height: 1px; width: 1px;"><!--  almost invisible block --></div>
<suspense *ngIf="footerIsVisible">
<ng-template [defaultView]="footerFactory" onEvent="footer"></ng-template>
<ng-template fallbackView><qp-suspense-spinner>Loading Footer links...</qp-suspense-spinner></ng-template>
<ng-template errorView><app-simple-footer /></ng-template>
</suspense>

The [intersectionRootMargin]="'0px 0px 200%'" will start loading the component once the footer is approximately 2 pages (200%) below the current page we are looking at. The visibilityChange @Output( )is a required function where something has to happen when the intersection is triggered, for example toggling the footerIsVisible variable from false to true.

Webpack magic: Prefetching dynamic imports

So far we have loaded lazy components handling the loading state (fallbackView), the error state ( errorView ), and in the time when the component is really needed (by using IntersectionObservers).

What if we could also separate each lazy loaded component in their own bundle, and give them certain loading priority?

And here come Webpack magic comments. As we all probably know, Angular uses Webpack for building the distributable bundle for our apps.

To get our desired results we are going to use 2 magic comments:

  1. webpackChunkName: The readable name for our component's chunk. Instead iof an ugly hash, we can use something more readable. Receives a string as the parameter value.
  2. webpackPrefetch : The priority for loading our script in the page. Think in our web apps as a movie script, when loading a page we know in some way which elements/components should be loaded first and which should be loaded last. Usual values are true or false, but this article shows an example where a number priority seem to work as expected.

Since version 5.87.0+ there's also a webpackFetchPriority magic comment, but I haven't tested how it behaves (you're free to do so and share your resluts 😃) .

So our footer component factory could end up looking like:

// ... some code before
footerFactory: SuspenseFactoryPromise = <SuspenseFactoryPromise>(() => import(
/* webpackChunkName: "footer" */
/* webpackPrefetch: -100 */
'./components/footer/footer.component') as unknown);
// ... some more code after

Future work

Angular future development be like… ehm, ok, no…

Before you ask I'll just throw the answer, "Of course there's room for improvement!". There are more than a couple of thing to be done with the QP Suspense library:

  1. Documentation in English. The library was initially developed for a Latin American Spanish speaking group of developers, but we all know that English is preferred by most developers. It gives any development that "international feel". Also, don't really know why, but libraries with non-English documentation are often not taken very seriously… 🤷‍♂
  2. Better examples. Arguable, but that's me against all of you. The example app is supposed to cover in a "clear way" all use cases, but it simply s%cks. It's ugly and poorly documented.
  3. Angular 16. OK, current Angular version brings lots of new features, including the capability of creating a lazy component and specifying it's @Input()s as the input attribute in a ngComponentOutlet. This is the link that includes the PR for this feature https://github.com/angular/angular/pull/50736 and it's nicely explained in this article. And the EventService is most likely to be replaced by some signals flavored solution. So when the time comes, there will be more than some changes in this library.

Thanks for reading so far!
I wonder why Angular (yet) doesn't already include something like React's suspense. There's a lot of really incredible new features in Angular 16, such as SSR non destructive hydration, which really helps improving UI/UX and performance… if you're into SSR. A framework-native suspense-like component or directive would be really nice for hydrating a CSR app.

And, in fact it's already, in a certain way, a topic in RFC: Deferred loading.

🎩Too many tricks for today 🪄✨

I hope you have learned a couple of extra tricks for your Angular development.
Stay safe!

--

--

Writer for

Polyglot senior software engineer, amateur guitar & bass player, geek, husband and dog father. Not precisely in that order.