Why handle errors with catchError and not in the subscribe error callback in Angular
1 It's all about separation of concern in Angular
One major benefit of using catchError
is to
separate the whole data retrieval logic including all errors that can occur along the way from the presentation of the data.
1.1 Let Components only care about the presentation of data
Components should only care about data (whether it's there or not). They shouldn't care about the specifics of how to retrieve data or all the things that could go wrong during data retrieval.
Components shouldn't fetch or save data directly and they certainly shouldn't knowingly present fake data. They should focus on presenting data and delegate data access to a service.
[Angular Tutorial - Why Services]
Let's say your data is a list of items. Your Component would call a service.getItemList()
function and, as it only cares about data, would expect:
- a list containing items
- an empty list
- no list i.e.
null
orundefined
You could easily handle all these cases with ngIf
in your Component template and display the data or something else depending on the case. Having a Service function return a clean Observable that only returns data (or null
) and isn't expected to throw any errors keeps the code in your Components lean as you can easily use the AsyncPipe in a template to subscribe.
1.2 Don't let Components care about data retrieval specifics like errors
Your data retrieval and error handling logic may change over time. Maybe you're upgrading to a new Api and suddenly have to handle different errors. Don't let your Components worry about that. Move this logic to a Service.
Removing data access from components means you can change your mind about the implementation anytime, without touching any components. They don't know how the service works. [Angular Tutorial - Get hero data]
1.3 Put the data retrieval and error handling logic in a Service
Handling errors is part of your data retrieval logic and not part of your data presentation logic.
In your data retrieval Service you can handle the error in detail with the catchError
operator. Maybe there are some things you want to do on all errors like:
- log it
- display a user oriented error message as a notification (see Show messages)
- fetch alternative data or return a default value
Moving some of this into a this.handleError('getHeroes', [])
function keeps you from having duplicate code.
After reporting the error to console, the handler constructs a user friendly message and returns a safe value to the app so it can keep working. [Angular Tutorial - HTTP Error handling]
1.4 Make future development easier
There may come a time when you need to call an existing Service function from a new Component. Having your error handling logic in a Service function makes this easy as you won't have to worry about error handling when calling the function from your new Component.
So it comes down to separating your data retrieval logic (in Services) from your data presentation logic (in Components) and the ease of extending your app in the future.
2 Keeping Observables alive
Another use case of catchError
is to keep Observables alive when you're constructing more complex chained or combined Observables. Using catchError
on inner Observables allows you to recover from errors and keep the outer Observable running. This isn't possible when you're using the subscribe error handler.
2.1 Chaining multiple Observables
Take a look at this longLivedObservable$
:
// will never terminate / error
const longLivedObservable$ = fromEvent(button, 'click').pipe(
switchMap(event => this.getHeroes())
);
longLivedObservable$.subscribe(console.log);
getHeroes(): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl).pipe(
catchError(error => of([]))
);
}
The longLivedObservable$
will execute a http request whenever a button is clicked. It will never terminate not even when the inner http request throws an error as in this case catchError
returns an Observable that doesn't error but emits an empty array instead.
If you would add an error callback to longLivedObservable$.subscribe()
and removed catchError
in getHeroes
the longLivedObservable$
would instead terminate after the first http request that throws an error and never react to button clicks again afterwards.
Excursus: It matters to which Observable you add catchError
Note that longLivedObservable$
will terminate if you move catchError
from the inner Observable in getHeroes
to the outer Observable.
// will terminate when getHeroes errors
const longLivedObservable = fromEvent(button, 'click').pipe(
switchMap(event => this.getHeroes()),
catchError(error => of([]))
);
longLivedObservable.subscribe(console.log);
getHeroes(): Observable<Hero[]> {
return this.http.get<Hero[]>(this.heroesUrl);
}
"Error" and "Complete" notifications may happen only once during the Observable Execution, and there can only be either one of them.
In an Observable Execution, zero to infinite Next notifications may be delivered. If either an Error or Complete notification is delivered, then nothing else can be delivered afterwards.
[RxJS Documentation - Observable]
Observables terminate when an Error (or Complete) notification is delivered. They can't emit anything else afterwards. Using catchError
on an Observable doesn't change this. catchError
doesn't allow your source Observable to keep emitting after an error occurred, it just allows you to switch to a different Observable when an error occurs. This switch only happens once as only one error notification can be delivered.
In the example above, when this.getHeroes()
errors this error notification is propagated to the outer stream leading to an unsubscribe from fromEvent(button, 'click')
and catchError
switching to of([])
.
Placing catchError
on the inner Observable doesn't expose the error notification to the outer stream. So if you want to keep the outer Observable alive you have to handle errors with catchError
on the inner Observable, i.e. directly where they occur.
2.2 Combining multiple Observables
When you're combining Observables e.g. using forkJoin
or combineLatest
you might want the outer Observable to continue if any inner Observable errors.
const animals$ = forkJoin(
this.getMonkeys(),
this.getGiraffes(),
this.getElefants()
);
animals$.subscribe(console.log);
getMonkeys(): Observable<Monkey[]> {
return this.http.get<Monkey[]>(this.monkeyUrl).pipe(catchError(error => of(null)));
}
getGiraffes(): Observable<Giraffe[]> {
return this.http.get<Giraffe[]>(this.giraffeUrl).pipe(catchError(error => of(null)));
}
getElefants(): Observable<Elefant[]> {
return this.http.get<Elefant[]>(this.elefantUrl).pipe(catchError(error => of(null)));
}
animals$
will emit an array containing the animal arrays it could fetch or null
where fetching animals failed. e.g.
[ [ Gorilla, Chimpanzee, Bonobo ], null, [ Asian Elefant, African Elefant ] ]
Here catchError
allows the animals$
Observable to complete and emit something.
If you would remove catchError
from all fetch functions and instead added an error callback to animals$.subscribe()
then animals$
would error if any of the inner Observables errors and thus not emit anything even if some inner Observables completed successfully.
To learn more read: RxJs Error Handling: Complete Practical Guide