RxJS is the perfect tool for implementing reactive programming paradigms to your software development. In general, software development handling errors gracefully is a fundamental piece of ensuring the integrity of the application as well as ensuring the best possible user experience.
In this article, we will look at how we handle errors with RxJS and then look at how we can use RxJS to build a simple yet performant application.
Handling Errors
Our general approach to errors usually consists of us exclaiming "Oh no! What went wrong?" but it's something that is a common occurrence in all applications. The ability to manage errors well without disrupting the user's experience, while also providing accurate error logs to allow a full diagnosis of the cause of the error, is more important than ever.
RxJS gives us the tools to do this job very well! Let's take a look at some basic error handling approaches with RxJS.
Basic Error Handling
The most basic way of detecting and reacting to an error that has occurred in an Observable stream provided to us by the .subscribe()
method.
obs$.subscribe(
value => console.log("Received Value: ", value),
error => console.error("Received Error: ", error)
)
Here we can set up two different pieces of logicβone to handle non-error emission from the Observable and one to gracefully handle errors emitted by the Observable.
We could use this to show a Notification Toast or Alert to inform the user that an error has occurred:
obs$.subscribe(
value => console.log("Received Value: ", value),
error => showErrorAlert(error)
)
This can help us minimize disruption for the user, giving them instant feedback that something actually hasn't worked as appropriate rather than leaving them to guess.
Composing Useful Errors
Sometimes, however, we may have situations wherein we want to throw an error ourselves. For example, some data we received isn't quite correct, or maybe some validation checks failed.
RxJS provides us with an operator that allows us to do just that. Let's take an example where we are receiving values from an API, but we encounter missing data that will cause other aspects of the app not to function correctly.
obs$
.pipe(
mergeMap((value) =>
!value.id ? throwError("Data does not have an ID") : of(value)
)
)
.subscribe(
(value) => console.log(value),
(error) => console.error("error", error)
);
If we receive a value from the Observable that doesn't contain an ID, we throw an error that we can handle gracefully.
NOTE: Using the throwError
will stop any further Observable emissions from being received.
Advanced Error Handling
We've learned that we can handle errors reactively to prevent too much disruption for the user.
But what if we want to do multiple things when we receive an error or even do a retry?
RxJS makes it super simple for us to retry errored Observables with their retry()
operator.
Therefore, to create an even cleaner error handling setup in RxJS, we can set up an error management solution that will receive any errors from the Observable, retry them in the hopes of a successful emission, and, failing that, handle the error gracefully.
obs$
.pipe(
mergeMap((value) =>
!value.id ? throwError("Data does not have an ID") : of(value)
),
retry(2),
catchError((error) => {
// Handle error gracefully here
console.error("Error: ", error);
return EMPTY;
})
)
.subscribe(
(value) => console.log(value),
() => console.log("completed")
);
Once we reach an error, emitting the EMPTY
observable will complete the Observable.
The output of an error emission above is:
Error: Data does not have an ID
completed
Usage in Frontend Development
RxJS can be used anywhere running JavaScript; however, I'd suggest that it's most predominately used in Angular codebases. Using RxJS correctly with Angular can massively increase the performance of your application, and also help you to maintain the Container-Presentational Component Pattern.
Let's see a super simple Todo app in Angular to see how we can use RxJS effectively.
Basic Todo App
We will have two components in this app: the AppComponent
and the ToDoComponent
.
Let's take a look at the ToDoComponent
first:
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output
} from "@angular/core";
export interface Todo {
id: number;
title: string;
}
@Component({
selector: "todo",
template: `
<li>
{{ item.title }} - <button (click)="delete.emit(item.id)">Delete</button>
</li>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ToDoComponent {
@Input() item: Todo;
@Output() delete = new EventEmitter<number>();
}
Pretty simple, right? It takes an item
input and outputs an event when the delete button is clicked. It performs no real logic itself other than rendering the correct HTML.
One thing to note is changeDetection: ChangeDetectionStrategy.OnPush
.
This tells the Angular Change Detection System that it should only attempt to re-render this component when the Input
has changed.
Doing this can increase performance massively in Angular applications and should always be applicable to pure presentational components, as they should only be rendering data.
Now, let's take a look at the AppComponent
.
import { Component } from "@angular/core";
import { BehaviorSubject } from "rxjs";
import { Todo } from "./todo.component";
@Component({
selector: "my-app",
template: `
<div>
<h1>
ToDo List
</h1>
<div style="width: 50%;">
<ul>
<todo
*ngFor="let item of (items$ | async); trackBy: trackById"
[item]="item"
(delete)="deleteItem($event)"
>
</todo>
</ul>
<input #todoTitle placeholder="Add item" /><br />
<button (click)="addItem(todoTitle.value, todoTitle)">Add</button>
</div>
</div>
`,
styleUrls: ["./app.component.css"]
})
export class AppComponent {
private items: Todo[] = [{ id: 1, title: "Learn RxJS" }];
items$ = new BehaviorSubject<Todo[]>(this.items);
addItem(title: string, inputEl: HTMLInputElement) {
const item = {
id: this.items[this.items.length - 1].id + 1,
title,
completed: false
};
this.items = [...this.items, item];
this.items$.next(this.items);
inputEl.value = "";
}
deleteItem(idToRemove: number) {
this.items = this.items.filter(({ id }) => id !== idToRemove);
this.items$.next(this.items);
}
trackById(index: number, item: Todo) {
return item.id;
}
}
This is a container component, and it's called this because it handles the logic relating to updating component state as well as handles or dispatches side effects.
Let's take a look at some areas of interest:
private items: Todo[] = [{ id: 1, title: "Learn RxJS" }];
items$ = new BehaviorSubject<Todo[]>(this.items);
We create a basic local store to store our ToDo items; however, this could be done via a state management system or an API.
We then set up our Observable, which will stream the value of our ToDo list to anyone who subscribes to it.
You may now look over the code and begin to wonder where we have subscribed to items$
.
Angular provides a very convenient Pipe that handles this for us. We can see this in the template:
*ngFor="let item of (items$ | async); trackBy: trackById"
In particular, it's the (items$ | async)
this will take the latest value emitted from the Observable and provide it to the template. It does much more than this though. It also will manage the subscription for us, meaning when we destroy this container component, it will unsubscribe automatically for us, preventing unexpected outcomes.
Using a pure pipe in Angular also has another performance benefit. It will only ever re-run the code in the Pipe if the input to the pipe changes. In our case, that would mean that item$
would need to change to a whole new Observable for the code in the async
pipe to be executed again. We never have to change item$
as our values are then streamed through the Observable.
Conclusion
Hopefully, you have learned both about how to handle errors effectively as well put RxJS into practice into a real-world app that improves the overall performance of your application. You should also start to see the power that using RxJS effectively can bring!