Handling Success/Failure/Loading when calling an API with Angular and ngrx

Imab Asghar
Code道
Published in
5 min readOct 6, 2020

We use backend APIs to serve almost every front-end feature we build. For example, to serve data to display in front-end, or to perform a CRUD operation. At times we have to show different UI to the user depending on the state of the API call. For example:

  • We will show a success toast and redirect to another page when the API returns with a success.
  • We will show a danger toast if the API returns an error.
  • We will just show loading meanwhile the API is taking its sweet time.
  • And dismiss the loading once the API has finished.

Since we use ngrx, we try not to call the http from the component (or using the service), but use the ngrx actions instead.

So for each API call we would have at least 3 actions:

export const loadBooks = createAction(
'[Books Page] Load Books',
);
export const loadBooksSuccess = createAction(
'[Books API] Load Books Success',
props<{ books: IBook[] }>(),
);
export const loadBooksFailure = createAction(
'[Books API] Load Books Failure',
props<{ errorMessage: string }>(),
);

Every developer had a different idea of implementing the UI states in the component. Since we had to implement quite a lot of times there was a lot of duplication which is not a good practice. Often we would define a ViewStatus enum on the state. However everyone had a different implementation of the viewStatus. So we consolidated and made a small and compact ViewStatus enum.

export enum ViewStatus {
Initial = 'INITIAL',
Loading = 'LOADING',
Success = 'SUCCESS',
Failure = 'FAILURE',
}

And use the ViewStatus in the reducer like this:

export interface IBooksPageState {
viewStatus: ViewStatus;
errorMessage?: string;
}
export const initialState: IBooksPageState = {
viewStatus: ViewStatus.Initial,
};
const booksPageReducer = createReducer(
initialState,
on(LoadBooks, () => ({viewStatus: ViewStatus.Loading})),
on(LoadBooksSuccess, () => ({viewStatus: ViewStatus.Success})),
on(LoadBooksFailure, (_, { errorMessage }) =>
({viewStatus: ViewStatus.Failure, errorMessage }),
),
);

Now the ViewStatus is on the state, we need to use the selector and subscribe in the component. However that implementation also differs again between different developers. Most will subscribe to this selector on component’s ngOnInit. And then unsubscribe on ngOnDestroy. I wanted to refactor this since I want to avoid ongoing subscriptions as much as possible. Hence, came up with the following component’s method:

private handleViewStatus() {  const viewStatus$ = 
this.bookPageStore.select(selectBookPageViewStatus)
.pipe(
skipWhile(viewStatus => viewStatus !== ViewStatus.Loading),
take(2),
);
viewStatus$
.pipe(
filter(viewStatus => viewStatus === ViewStatus.Loading)
)
.subscribe(() => {
// Show loading
})
viewStatus$
.pipe(
filter(viewStatus => viewStatus === ViewStatus.Success)
)
.subscribe(() => {
// Hide Loading
// Show Success Toast
}) viewStatus$
.pipe(
filter(viewStatus => viewStatus === ViewStatus.Failure)
)
.subscribe(() => {
// Hide Loading
// Show Error Toast
})
}

Explanation: The initial skipWhile makes sure we get ViewStatus.Loading as the first signal. Followed by ViewStatus.Success or ViewStatus.Failure. The take(2) ensures we get these two signals only. And you want to call this method before dispatching the LoadBooks action.

I sent this code for review and got the code feedback that we should name each operation. So it is more readable and maintainable. Also, we should separate the loading out of success and failure blocks.

private handleViewStatus() {  const viewStatus$ = 
this.bookPageStore.select(selectBookPageViewStatus)
.pipe(
skipWhile(viewStatus => viewStatus !== ViewStatus.Loading),
take(2),
);
const loading$ = viewStatus$
.pipe(
filter(viewStatus => viewStatus === ViewStatus.Loading)
)
loading$
.subscribe(() => {
// Show loading
})
const loadingComplete$ = viewStatus$
.pipe(
filter(viewStatus => viewStatus !== ViewStatus.Loading)
)
loadingComplete$
.subscribe(() => {
// Hide loading
})
const success$ = viewStatus$
.pipe(
filter(viewStatus => viewStatus === ViewStatus.Success)
)
success$
.subscribe(() => {
// Show Success Toast
})
const failure$ = viewStatus$
.pipe(
filter(viewStatus => viewStatus === ViewStatus.Failure)
)
failure$
.subscribe(() => {
// Show Error Toast
})
}

So far so good, but it is still a big chunk of code we would have to copy-paste in all the components. Additionally there are a lot of impurities in a single function and I would not be able to sleep if I don’t remove these. So I extracted this complication to a separate pure function. Used ramda since I am a big fan of ramda and then I can proceed with writing a marble test to confirm my assumption.

export const viewStatusToStreams = 
(viewStatusSelector$: Observable<ViewStatus>) => {
const viewStatus$ =
viewStatusSelector$
.pipe(
skipWhile(complement(equals(ViewStatus.Loading))),
take(2),
);
const [loading$, loadingComplete$] =
partition(viewStatus$, equals(ViewStatus.Loading))

const success$ = viewStatus$
.pipe(
filter(equals(ViewStatus.Success))
)
const failure$ = viewStatus$
.pipe(
filter(equals(ViewStatus.Failure))
)
return { loading$, loadingComplete$, success$, failure$ }; }

The marble test for API success and failure cases:

import { cold } from 'jasmine-marbles';describe('viewStatusToStreams', () => {
const mapping = {
i: ViewStatus.Initial,
l: ViewStatus.Loading,
s: ViewStatus.Success,
f: ViewStatus.Failed,
};
describe('API call succeeds', () => {
const viewStatus$ = of(
ViewStatus.Initial,
ViewStatus.Loading,
ViewStatus.Success
);
const { success$, failure$, loading$, loadingComplete$ } = viewStatusToStreams(viewStatus$);
it('should send loading signal once and then complete', () => {
const expected = cold('(l|)', mapping);
expect(loading$).toBeObservable(expected);
});
it('should send success signal once and then complete', () => {
const expected = cold('(s|)', mapping);
expect(success$).toBeObservable(expected);
});
it('should send success signal once to the loadingComplete as well and then complete', () => {
const expected = cold('(s|)', mapping);
expect(loadingComplete$).toBeObservable(expected);
});
it('should not send signal to failure but complete', () => {
const expected = cold('(|)', mapping);
expect(failure$).toBeObservable(expected);
});
});
describe('API call fails', () => {
const viewStatus$ = of(
ViewStatus.Failed, // LoadFailed from the previous API Call
ViewStatus.Loading,
ViewStatus.Failed,
ViewStatus.Loading, // This would be ignored because observables have finished
ViewStatus.Success, // This would be ignored because observables have finished
);
const { success$, failure$, loading$, loadingComplete$ } = viewStatusToStreams(viewStatus$);
it('should send loading signal once and then complete', () => {
const expected = cold('(l|)', mapping);
expect(loading$).toBeObservable(expected);
});
it('should send failure signal once and then complete', () => {
const expected = cold('(f|)', mapping);
expect(failure$).toBeObservable(expected);
});
it('should send failure signal once to the loadingComplete as well and then complete', () => {
const expected = cold('(f|)', mapping);
expect(loadingComplete$).toBeObservable(expected);
});
it('should not send signal to success but complete', () => {
const expected = cold('(|)', mapping);
expect(success$).toBeObservable(expected);
});
});
});
Marble diagram for API Success scenario

and in the component:

private handleViewStatus() {  const { loading$, loadingComplete$, success$, failure$ } = 
viewStatusToStreams(
this.booksPageStore.select(selectBooksPageViewStatus)
)
loading$
.subscribe(() => {
// Show loading
})
loadingComplete$
.subscribe(() => {
// Hide loading
})
success$
.subscribe(() => {
// Show Success Toast
})
failure$
.subscribe(() => {
// Show Error Toast
})
}

As you can see the implementation is now extracted to a pure function and abstracted from the implementation in the component. Also gives us flexibility in case we need to use only some of these streams.

Thanks for reading! And I hope you find this article useful. Feel free to post any questions or suggestions on how to further improve it.

--

--