Brandon Roberts headshot rounded

Brandon Roberts

Notes to my future self

Lazy Loading Routes using an Observable with the Angular Router

July 06, 2020 - 10 min read

As I'm prone to do sometimes, I go digging through the Angular Router source code to see how it works. What I didn't expect to find was a different way to lazy load routes. A typical routing setup with lazy loading looks like this:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () => import('./lazy/lazy.module').then((m) => m.LazyModule),
  },
  { path: '', redirectTo: 'lazy', pathMatch: 'full' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule {}

The snippet above uses the loadChildren property in the Route object to lazy load the route using a Promise. This API is defined as the LoadChildrenCallback as part of the public API of the Angular Router. Looking at the signature for the type, it has many different ways to load a module.

type LoadChildrenCallback = () =>
  | Type<any>
  | NgModuleFactory<any>
  | Observable<Type<any>>
  | Promise<NgModuleFactory<any> | Type<any> | any>;

All previous examples of lazy loading routes just use a callback than returns a Promise. To my surprise, you can also return an observable to handle lazy loading of routes. To make sure this wasn't something new, I went back through the commits, and I had to go back 4 years to find the commit that introduced this option. This seamlessly works in either scenario because the router will wrap a Promise in an observable if its not one already.

Below is a snippet of the utility function:

export function wrapIntoObservable<T>(
  value: T | Promise<T> | Observable<T>
): Observable<T> {
  if (isObservable(value)) {
    return value;
  }

  if (isPromise(value)) {
    // Use `Promise.resolve()` to wrap promise-like instances.
    return from(Promise.resolve(value));
  }

  return of(value);
}

Knowing that information, instead of providing a callback that returns a Promise, a callback that returns an observable works also. This opens up much more flexiblity when handling lazy loading. Let's look at some examples.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { Observable } from 'rxjs';

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () =>
      new Observable((observer) => {
        import('./lazy/lazy.module').then(
          (m) => {
            observer.next(m.LazyModule);
            observer.complete();
          },
          (error) => {
            observer.error(error);
          }
        );
      }),
  },
  { path: '', redirectTo: 'lazy', pathMatch: 'full' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule {}

This example creates a new Observable, wraps the promise returned by the dynamic import, pushes a value and completes if the promise resolves, or pushes an error if the request fails.

Another example would be to use the from operator.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { from } from 'rxjs';
import { map } from 'rxjs/operators';

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () =>
      from(import('./lazy/lazy.module')).pipe(map((m) => m.LazyModule)),
  },
  { path: '', redirectTo: 'lazy', pathMatch: 'full' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule {}

The example above of a new observable that wraps the promise is a basic equivalent of the from operator in the RxJS library. The from observable creation operator creates an observable that came from a promise. Combined with the map operator, the same result is achieved.

What about logging? The tap operator is useful for debugging.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { from } from 'rxjs';
import { map, tap } from 'rxjs/operators';

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () =>
      from(import('./lazy/lazy.module')).pipe(
        map((m) => m.LazyModule),
        tap(
          () => {
            console.log('Lazy Module Loaded');
          },
          () => {
            console.log('Lazy Module Load Failed');
          }
        )
      ),
  },
  { path: '', redirectTo: 'lazy', pathMatch: 'full' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule {}

With access to the Observable primitive, and its set of operators, with some slight modification, retrying a failed request is pretty straightforward also.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { defer } from 'rxjs';
import { map, retry, tap } from 'rxjs/operators';

const routes: Routes = [
  {
    path: 'lazy',
    loadChildren: () =>
      defer(() => import('./lazy/lazy.module')).pipe(
        map((m) => m.LazyModule),
        retry(2)
      ),
  },
  { path: '', redirectTo: 'lazy', pathMatch: 'full' },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
})
export class AppRoutingModule {}

The defer operator returns a new observable using a factory function each time its subscribed to. Compared to the from example above, adding the retry(2) to the observable wouldn't behave the way you'd expect. Because promises only resolve once, in order to retry the promise in case of a failure, a new promise needs to be returned each time we make the request.

Should you start using observables to lazy load your routes instead? Maybe not, but having the option to use an observable with the Angular Router to lazy load routes opens up a new set of possibilities that have been hiding in plain sight all along.

Follow me on Twitter, YouTube, Twitch, and consider sponsoring me on GitHub.