Dai Codes: A Blog

Yet another software engineerʼs blog

A picture of a pixelated, old-fashioned loading progress bar

Handling data loading states in Angular with RxJs

Using a single Observable to drive the loading, loaded and error UI states in Angular

A common pattern (or rather, anti-pattern) that I have seen used in Angular components is using a pair of BehaviorSubjects to indicate whether to show a loading spinner or an error message, with these driven by taps on an Observable<HttpResponse>:

/**
* BAD PRACTICE EXAMPLE: DO NOT DO THIS!
*/

export class SomeComponent {
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly myDataService: MyDataService
) {}

readonly loading$ = new BehaviorSubject<boolean>(false);
readonly error$ = new BehaviorSubject<HttpErrorResponse | Error | undefined>(undefined);
readonly myData$ = this.activatedRoute.params.pipe(
pluck('id'),
tap(() => {
this.loading$.next(true);
this.error$.next(undefined);
}),
switchMap(
(id) => this.myDataService.getMyData(id).pipe(
catchError(error => {
this.error$.next(error);
return of(undefined);
}),
tap(() => this.loading$.next(false)),
startWith(undefined as void)
)
)
);
}

The associated HTML template will often look something like this:

<my-loading-spinner *ngIf="loading$ | async"></my-loading-spinner>
<my-error-component *ngIf="error$ | async as error" [error]="error"></my-error-component>
<my-data-component *ngIf="myData$ | async as data" [data]="data"></my-data-component>

Using tap in one Observable to call next on a subject elsewhere is generally a code smell. As the guys over at Oasis Digital explain in this video, it's like trying to get water from point A to point B by manually filling up a bucket from point A, then tipping it into a sink leading to point B. A better solution would be to just join up the points with a pipe for the water to flow through of its own accord.

One Observable to rule them all

Instead of driving the UI with three separate Observables, we can create a "state" interface to wrap the entire state of the request (loading, error and data) and observe a stream of the state changes:

export interface HttpRequestState<T> {
isLoading: boolean;
value?: T;
error?: HttpErrorResponse | Error;
}

export class SomeComponent {
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly myDataService: MyDataService
) {}

readonly myDataState$: Observable<HttpRequestState<MyData>> = this.activatedRoute.params.pipe(
pluck('id'),
switchMap(
(id) => this.myDataService.getMyData(id).pipe(
map((value) => ({isLoading: false, value})),
catchError(error => of({isLoading: false, error})),
startWith({isLoading: true})
)
),
);
}

Now we have a much simpler pipe, and just one Observable. We can use this in our HTML template like so:

<ng-container *ngIf="myDataState$ | async as data">
<my-loading-spinner *ngIf="data.isLoading"></my-loading-spinner>
<my-error-component *ngIf="data.error" [error]="data.error"></my-error-component>
<my-data-component *ngIf="data.value" [data]="data.value"></my-data-component>
</ng-container>

Or even better, if you have proper separation of concerns with smart/dumb components (and you really should), your presentational component could contain an input for the HttpRequestState which your smart component can async-pipe into:

// presentational component class
export class SomeLayoutComponent {
@Input()
state: HttpRequestState<MyData>;
}
<!-- Presentational component template -->
<my-loading-spinner *ngIf="state.isLoading"></my-loading-spinner>
<my-error-component *ngIf="state.error" [error]="state.error"></my-error-component>
<my-data-component *ngIf="state.value" [data]="state.value"></my-data-component>
<!-- Smart component template -->
<some-layout-component
[state]="myDataState$ | async"
>
</some-layout-component>

Multicasting into separate Observables

If you really want to keep three separate Observables, you can do so quite easily by using shareReplay on the state Observable and having separate Observables pipe from that:

export class SomeComponent {
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly myDataService: MyDataService
) {}

readonly myDataState$: Observable<HttpRequestState<MyData>> = this.activatedRoute.params.pipe(
pluck('id'),
switchMap(
(id) => this.myDataService.getMyData(id).pipe(
map((value) => ({isLoading: false, value})),
catchError(error => of({isLoading: false, error})),
startWith({isLoading: true}),
shareReplay(1) // Added shareReplay to allow multicasting this
)
),
);

readonly loading$ = this.myDataState$.pipe(map(state => state.isLoading));
readonly error$ = this.myDataState$.pipe(map(state => state.error));
readonly myData$ = this.myDataState$.pipe(map(state => state.data));
}

shareReplay(1) is preferred over share() for robustness, as it ensures none of the subscriptions miss the initial state.

ngx-http-request-state

The library ngx-http-request-state provides the HttpRequestState interface illustrated above, as well as a custom RxJs operator called httpRequestStates which encapsulates the map, catchError and startWith operator chain used above to convert an Observable<T | HttpResponse<T>> into an Observable<HttpRequestState<T>>:

import { HttpRequestState, httpRequestStates } from 'ngx-http-request-state';

export class SomeComponent {
constructor(
private readonly activatedRoute: ActivatedRoute,
private readonly myDataService: MyDataService
) {}

readonly myDataState$: Observable<HttpRequestState<MyData>> = this.activatedRoute.params.pipe(
pluck('id'),
switchMap(
(id) => this.myDataService.getMyData(id).pipe(
httpRequestStates(),
// shareReplay(1) if you want to multicast
)
),
);
}

Our component now has a single Observable with a simple pipe that's easy to read and understand.

Testing

We can use marbles testing to easily test the loading state Observable in our component, including error handling and continuity after errors:

import { errorState, loadedState, loadingState } from 'ngx-http-request-state';
import { cold } from 'jest-marbles';
// etc

describe('SomeComponent', () => {
const fakeError: HttpErrorResponse = <any> {
message: 'Fake error'
};

/**
* Helper function to create a mock data service.
*
* If given the string 'E', the mock getMyData function will return
* an observable which throws the fakeError defined above after a
* delay of five frames.
*
* Otherwise, it will return an observable that emits a fake MyData
* object with the given string as the 'id' property, again after a
* delay of five frames.
*/

function createMockDataService(): MyDataService {
return <any>{
getMyData: (id: string) => {
if (id === 'E') {
return cold('-----#', undefined, fakeError);
}
return cold('-----(d|)', {
d: {id}
});
}
};
}

/**
* Helper function to create a mock ActivatedRoute whose params property
* is a cold observable built from the given marbles. The observable
* will emit a Params instance containing an id property set to the value
* of each marble in the given marbles string.
*/

function createMockActivatedRoute(paramsMarbles: string): ActivatedRoute {
return <any>{
params: cold(paramsMarbles).pipe(
map((id: string) => ({id}))
)
};
}

describe('#myDataState$', () => {
it('should emit loading states in response to route param changes', () => {

// routeParams is the route param changes, states is the
// expectedStates output of our observable.
//
// We've space-padded the routeParams marbles so that
// the two timelines align nicely.
//
// Note that the params marbles includes a change
// which is shorter than the five-frame response delay
// our mock service uses; this is so we can verify that
// in-flight requests are dropped when a new route param
// value is emitted before a response is received.
//
const routeParams = ' -a--------b--c------d--------|';
const expectedStates = '-L----a---L--L----c-L----d---|';

const component = new SomeComponent(
createMockActivatedRoute(routeParams),
createMockDataService(),
);

expect(component.myDataState$).toBeObservable(
cold(expectedStates, {
L: loadingState(),
a: loadedState({id: 'a'}),
c: loadedState({id: 'c'}),
d: loadedState({id: 'd'}),
})
);
});

it('should continue to respond to route param changes after an earlier request errors', () => {

// A common gotcha with switchMap is that catchError must
// be applied to the inner observable, otherwise the outer
// observable is unsubscribed when the inner observable
// throws an error.
//
// So here we're verifying that if the service returns an
// observable which throws an error, our component's
// observable does not complete, but continues to respond
// to changes to the route params after the error state.
//
const routeParams = ' -a--------E--------b--------|';
const expectedStates = '-L----a---L----E---L----b---|';

const component = new SomeComponent(
createMockActivatedRoute(routeParams),
createMockDataService(),
);

expect(component.myDataState$).toBeObservable(
cold(expectedStates, {
L: loadingState(),
E: errorState(fakeError),
a: loadedState({id: 'a'}),
b: loadedState({id: 'b'}),
})
);
});
});
});

In this example, we're using jest and jest-marbles, but the same principles apply if you're using jasmine and jasmine-marbles.

For examples of e2e tests, see my previous article on using Cypress to test loading spinner states.

Discuss

Comments or questions? Join the conversation on Twitter: