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 BehaviorSubject
s 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 Observable
s, 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 Observable
s, you can do so quite easily by using shareReplay
on the state Observable
and having separate Observable
s 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.
Support
If you've found my blog useful, please consider buying me a coffee to say thanks:
Discuss
Comments or questions? Join the conversation on Twitter: