Angular RxJS User interface Reactivity Web

At SketchUp I was running a team of developers making a complex Angular application. One of the things that kept producing lots of fragile code that was hard to test, and that was developed independently in a lot of different components, was locking parts of the interface while data was loading. Furthermore, we had to support real-time streams via WebSockets.

The answer was one component that you can wrap around any part of the site, and a pipe that fetches data straight from the template: 'url' | httpGet. Let's look at the problem first.

Loading in one component

Here's the usual way for a component to load something and show a spinner while it does:

@Component({
    selector: 'app-project',
    template: `
        <div class="spinner" *ngIf="loading"></div>
        <h1 *ngIf="!loading">{{ project?.name }}</h1>
    `,
})
export class ProjectComponent implements OnInit, OnDestroy {
    @Input() projectId: number;
    project: Project;
    loading = false;
    private subscription: Subscription;

    constructor(private http: HttpClient) {}

    ngOnInit() {
        const url = '/api/projects/' + this.projectId;
        this.loading = true;
        this.subscription = this.http.get<Project>(url).subscribe(
            (project) => {
                this.project = project;
                this.loading = false;
            },
            () => {
                this.loading = false;
            },
        );
    }

    ngOnDestroy() {
        this.subscription.unsubscribe();
    }
}
Loading a project with a flag and a subscription

That's a lot of code for showing one name, and it still isn't right. If projectId changes, ngOnInit doesn't run again, so it carries on showing the old project; fixing that needs ngOnChanges, and cancelling the request that's already on its way. Only the heading is hidden while it loads, so anything else in the component can still be clicked. And every one of those paths needs a test.

Loading in hundreds of components

Now scale that up to hundreds of components, loading things at various different points as people use the interface: opening a panel, choosing a tab, expanding a row.

Every developer wrote it a little differently. Some components locked their buttons while they loaded and some didn't, so you could click twice, or edit something that was about to be replaced. A panel showing three things from three requests needed forkJoin or combineLatest just to know when they'd all arrived. A parent that had to stay locked until its children had loaded needed loading flags passed up through outputs and back down through inputs. And a WebSocket stream had its own idea of loading: waiting for the first value to arrive.

The loading code was spread through every component, and that's where the bugs were.

A loader and a pipe

With the loader, the same component is just a template:

<app-loader>
    <ng-container *ngIf="('/api/projects/' + projectId) | httpGet as project">
        <h1>{{ project.name }}</h1>
        <app-model-list [projectId]="projectId"></app-model-list>
    </ng-container>
</app-loader>
The project component's template
<ul>
    <li *ngFor="let model of ('/api/projects/' + projectId + '/models') | httpGet">
        {{ model.name }}
    </li>
</ul>
The model list component's template

Neither component has a flag, a subscription or a lifecycle method. While the project loads, the loader hides its content and shows a spinner. When the project arrives, the model list appears and starts loading its models, and the loader stays locked until they've arrived too. If projectId changes, both pipes see a new URL and fetch again, and the loader locks while they do. Neither component knows the loader is there.

The pipes find the loader through dependency injection. The loader provides a LoadingManager, and every httpGet pipe inside it, however deep in child components, is given that manager when it's created. Each pipe registers itself, says when it starts and finishes loading, and unregisters when it's destroyed. The loader watches the manager, and hides its content while anything is loading:

Pipes anywhere inside the loader report to its manager
Pipes anywhere inside the loader report to its manager

There are three pieces: the loader component, the manager, and the pipe.

The loader component

import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { LoadingManager } from './loading-manager';

@Component({
    selector: 'app-loader',
    template: `
        <div [class.hidden]="loading$ | async">
            <ng-content></ng-content>
        </div>
        <div class="spinner" *ngIf="loading$ | async"></div>
    `,
    styles: [`
        :host { display: block; position: relative; min-height: 48px; }
        .hidden { visibility: hidden; }
        .spinner { position: absolute; top: 12px; left: 50%; margin-left: -12px; width: 24px; height: 24px;
                   border: 3px solid #c9d6da; border-top-color: #0e7490; border-radius: 50%;
                   animation: spin 0.8s linear infinite; }
        @keyframes spin { to { transform: rotate(360deg); } }
    `],
    providers: [LoadingManager],
})
export class LoaderComponent {
    loading$: Observable<boolean>;

    constructor(manager: LoadingManager) {
        this.loading$ = manager.loading$;
    }
}
The loader component

Each loader has its own manager, because it's in the component's providers. That's also what makes it visible to the content projected into the loader, and not just to the loader's own template (viewProviders would only reach the template). The loader itself does nothing but watch loading$.

Hiding, not removing

The content is hidden with CSS rather than taken out of the page, and it has to be. The pipes that are loading live in that content. If it were destroyed while it loaded, which is what *ngIf does to whatever it wraps, they'd unregister, the manager would say nothing is loading, the content would come back, the pipes would start loading again, and round it would go.

Hiding it has other advantages too. The page keeps its layout, the components keep their state while the next data loads, and because nothing with visibility: hidden can be clicked or focused, the area is locked. There are no double submits, and no editing something that's about to be replaced.

The loading manager

The manager keeps a list of the pipes inside its loader and whether each one is loading, and turns that into one observable: is anything loading?

import { Injectable } from '@angular/core';
import { BehaviorSubject, asapScheduler } from 'rxjs';
import { distinctUntilChanged, map, observeOn } from 'rxjs/operators';

@Injectable()
export class LoadingManager {
    //  Every pipe inside the loader, and whether it's loading right now
    private pipes = new Map<object, boolean>();
    private count = new BehaviorSubject<number>(0);

    readonly loading$ = this.count.pipe(
        map((count) => count > 0),
        distinctUntilChanged(),
        observeOn(asapScheduler),
    );

    register(pipe: object) {
        this.pipes.set(pipe, false);
    }

    unregister(pipe: object) {
        this.pipes.delete(pipe);
        this.update();
    }

    setLoading(pipe: object, loading: boolean) {
        this.pipes.set(pipe, loading);
        this.update();
    }

    private update() {
        let count = 0;
        this.pipes.forEach((loading) => {
            if (loading) {
                count++;
            }
        });
        this.count.next(count);
    }
}
The loading manager

The count of pipes that are loading goes into a BehaviorSubject, so the loader gets the current state as soon as it subscribes. distinctUntilChanged means the loader only hears about it when the answer changes, not every time a pipe starts or finishes.

observeOn(asapScheduler) deals with the timing. The pipes report while Angular is in the middle of change detection, working through the templates. A component inside the loader is checked after the loader itself, so when it starts loading, the loader has already been drawn as not loading, and changing that straight away gives an ExpressionChangedAfterItHasBeenCheckedError in development. observeOn(asapScheduler) delivers each change just after change detection has finished instead, and Angular then checks the loader again.

The httpGet pipe

import { ChangeDetectorRef, OnDestroy, Optional, Pipe, PipeTransform } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { LoadingManager } from './loading-manager';

@Pipe({ name: 'httpGet', pure: false })
export class HttpGetPipe implements PipeTransform, OnDestroy {
    private url: string | null = null;
    private value: any = null;
    private subscription: Subscription | null = null;

    constructor(
        private http: HttpClient,
        private changeDetector: ChangeDetectorRef,
        @Optional() private manager: LoadingManager,
    ) {
        if (this.manager) {
            this.manager.register(this);
        }
    }

    transform(url: string | null): any {
        if (url !== this.url) {
            this.url = url;
            this.load(url);
        }
        return this.value;
    }

    private load(url: string | null) {
        this.stop();
        if (url === null) {
            return;
        }
        this.setLoading(true);
        this.subscription = this.http.get(url).subscribe(
            (value) => {
                this.value = value;
                this.setLoading(false);
                this.changeDetector.markForCheck();
            },
            () => {
                this.setLoading(false);
            },
        );
    }

    private setLoading(loading: boolean) {
        if (this.manager) {
            this.manager.setLoading(this, loading);
        }
    }

    private stop() {
        if (this.subscription) {
            this.subscription.unsubscribe();
            this.subscription = null;
            this.setLoading(false);
        }
    }

    ngOnDestroy() {
        this.stop();
        if (this.manager) {
            this.manager.unregister(this);
        }
    }
}
The httpGet pipe

The pipe uses its constructor to register with the manager it's been given, and ngOnDestroy to unregister. In between it tells the manager whenever it starts or finishes loading.

It's an impure pipe (pure: false), so Angular calls transform on every change detection, not just when the URL changes. That's what lets it return the value once it arrives. transform stays cheap: it compares the URL with the last one, and only fetches when it's different. If the URL changes while a request is still on its way, that request is unsubscribed, which cancels it, so an old response can never replace a newer one.

While the next URL loads, the pipe keeps returning the last value, so the content doesn't collapse behind the loader. markForCheck makes it work inside OnPush components too. And the manager is @Optional, so outside any loader the pipe still fetches, it just has nobody to tell.

Live streams

A stream from a WebSocket fits the same pattern. Until its first value arrives, it's loading; after that, each new value just updates the screen. A second pipe does this for any observable. It's the httpGet pipe with the observable passed in, in place of the URL, and it stays subscribed:

@Pipe({ name: 'live', pure: false })
export class LivePipe implements PipeTransform, OnDestroy {
    private source: Observable<any> | null = null;
...
    transform(source: Observable<any> | null): any {
        if (source !== this.source) {
            this.source = source;
            this.listen(source);
        }
        return this.value;
    }

    private listen(source: Observable<any> | null) {
        this.stop();
        if (source === null) {
            return;
        }
        this.setLoading(true);
        this.subscription = source.subscribe(
            (value) => {
                this.value = value;
                this.setLoading(false);
                this.changeDetector.markForCheck();
            },
            () => {
                this.setLoading(false);
            },
        );
    }
...
}
The live pipe, where it differs from httpGet (trimmed)
@Component({
    selector: 'app-status',
    template: `
        <app-loader>
            <p>{{ (status$ | live)?.message }}</p>
        </app-loader>
    `,
})
export class StatusComponent {
    status$ = webSocket('wss://example.com/status');
}
Showing a WebSocket stream, locked until its first message

Things to know

  • Each | httpGet in a template is its own pipe, with its own request. Use the same URL twice and it's fetched twice, so fetch it once and give it a name with as.
  • Loaders nest. A pipe reports to the nearest loader above it, so a loader around one panel locks just that panel, and the page's loader doesn't wait for it.
  • The pipe finds its manager from the element it's used on, which is how pipes get their dependencies with Ivy, Angular's renderer. That's what lets a pipe in the page's own template, inside <app-loader>, find the loader's manager.
  • On an error the pipe simply stops loading. The manager is the natural place to collect errors as well, so that the loader can show a message instead of the content.