DEV Community

Cover image for Building Custom RxJS Operators for HTTP Requests
Cezar Pleșcan
Cezar Pleșcan

Posted on • Edited on

1

Building Custom RxJS Operators for HTTP Requests

Introduction

In this article I'll focus on how to efficiently structure the logic in the HTTP request stream pipelines for the loading and saving of the user data. Currently, the entire logic is handled within the UserProfileComponent class. I'll refactor this to achieve a more declarative and reusable approach.

A quick note:

  • Before we begin, please note that this article builds on concepts and code introduced in previous articles of this series. If you're new here, I highly recommend that you check out those articles first to get up to speed.
  • The starting point for the code I'll be working with in this article can be found in the 14.image-form-control branch of the repository https://github.com/cezar-plescan/user-profile-editor/tree/14.image-form-control.

In this article I'll guide you through:

  • How to break down complex logic into smaller, reusable RxJS operators.
  • The role of the catchError operator in error handling.
  • Strategies for handling validation errors, upload progress, and successful responses within observable pipelines.
  • The benefits of using custom operators for cleaner, more maintainable code.
  • How to create custom operators like tapValidationErrors, tapUploadProgress, tapResponseBody, and tapError to make the code more streamlined and easier to manage.

By the end of this article, you'll have a deeper understanding of how to leverage custom RxJS operators to manage the intricacies of HTTP requests and responses in your Angular applications.

Identifying the current issues

The code of the saveUserData() method of the UserProfileComponent class in the user-profile.component.ts file looks like this:

protected saveUserData() {
// set the saving flag
this.isSaveRequestInProgress = true;
this.saveUserData$()
.pipe(
finalize(() => {
// clear the saving flag
this.isSaveRequestInProgress = false;
// reset the progress
this.uploadProgress = 0;
}),
catchError(error => {
// handle server-side validation errors
if (error instanceof HttpErrorResponse && error.status === HttpStatusCode.BadRequest) {
this.setFormErrors(error.error)
return EMPTY;
}
// display a notification when other errors occur
this.notification.display('An unexpected error has occurred. Please try again later.')
// if there is another type of error, throw it again.
throw error;
})
)
.subscribe((event) => {
if (event.type === HttpEventType.UploadProgress) {
this.uploadProgress = event.total ?
Math.round(100 * event.loaded / event.total) :
100;
}
else if (event.type === HttpEventType.Response) {
// store the user data
this.userData = event.body!.data
// update the form with the values received from the server
this.restoreForm();
// display a success notification
this.notification.display('The profile was successfully saved');
}
})
}

The method is quite lengthy and handles multiple tasks, such as detecting response error types and computing upload progress. This violates the Single Responsibility Principle, making the code less maintainable. Ideally, the component should only be concerned with receiving errors, user data, and upload progress, not the intricacies of error handling or progress calculation.

To address all these, I'll create custom RxJS operators to handle different aspects of the HTTP request stream. This approach will lead to a more declarative and reusable code structure.

I'll start by implementing an operator for validation error handling, which I'll name tapValidationErrors.

Handling validation errors: the tapValidationErrors operator

The core idea behind this operator is to apply the extract method refactoring technique. I'll move the validation error handling code from the component into a separate, reusable function that can be used in the observable pipe chain.

The reason behind naming this operator is that the tap prefix aligns with the RxJS convention of using it for operators that perform side effects (like logging or triggering actions) without modifying the values in the stream. While this operator doesn't directly modify values, it does perform the side effect of invoking the callback function for validation errors.

Benefits of this approach

  • Separation of concerns: The error handling logic is decoupled from the main subscription logic, improving code organization and readability.
  • Reusability: The operator can be easily reused across different observables and components that need to handle validation errors in a similar way, promoting a DRY (Don't Repeat Yourself) approach.
  • Flexibility: The operator provides a clear way to customize the error handling behavior for validation errors without affecting the handling of other types of errors.

Implementation and usage

Let's create a new file, tap-validation-errors.ts, in the src/app/shared/rxjs-operators folder with the following content:

/**
* A custom RxJS operator that taps into the observable stream to handle HTTP validation errors.
*
* @param callback A function to be called when a validation error (HTTP 400 Bad Request) occurs.
* This function receives the HttpErrorResponse object containing the error details.
*
* @returns An RxJS operator function that catches validation errors, calls the callback function,
* and then completes the stream to prevent further processing.
*/
export function tapValidationErrors<T>(callback: (error: HttpErrorResponse ) => void ): OperatorFunction<HttpEvent<T>, HttpEvent<T>> {
return catchError((error: HttpErrorResponse | Error) => {
// Check if the error is an HttpErrorResponse with status code 400 (Bad Request), which is the format defined by the server
if (error instanceof HttpErrorResponse && error.status === HttpStatusCode.BadRequest) {
// Invoke the callback to handle the validation error
callback(error)
// Return EMPTY to complete the stream and prevent further processing.
return EMPTY;
}
// Re-throw other errors to be handled elsewhere in the observable chain.
throw error;
})
}
I've also updated the saveUserData() method in the user-profile.component.ts file:
protected saveUserData() {
// set the saving flag
this.isSaveRequestInProgress = true;
this.saveUserData$()
.pipe(
finalize(() => {
// ... the previous code
}),
// handle server-side validation errors
tapValidationErrors(errors => {
this.setFormErrors(errors.error)
}),
catchError(error => {
// display a notification when other errors occur
this.notification.display('An unexpected error has occurred. Please try again later.')
// Return EMPTY to complete the stream and prevent further processing.
return EMPTY;
})
)
.subscribe((event) => {
// ... the previous code
})
}

Let's break down what I've done here. I'll first examine the method in the component.

Simplifying the saveUserData() method

The catchError operator is now responsible for only handling global errors.

I've introduced the new operator tapValidationErrors before it. This order is crucial, as placing catchError before tapValidationErrors would cause it to catch and handle all errors, including validation errors, preventing tapValidationErrors from specifically addressing those validation errors. By placing tapValidationErrors first, I ensure that validation errors are identified and processed before any other error handling logic.

How the operator works

Now let's talk about the logic inside the tapValidationErrors operator.

After extracting the validation error handling code from the catchError block from the saveUserData() method, I could have simply used two separate catchError operators in the pipeline: one dedicated to validation errors and the other for general errors. While this would separate the logic, it wouldn't necessarily improve reusability or code organization.

protected saveUserData() {
// set the saving flag
this.isSaveRequestInProgress = true;
this.saveUserData$()
.pipe(
finalize(...),
catchError(error => {
// handle server-side validation errors
if (error instanceof HttpErrorResponse && error.status === HttpStatusCode.BadRequest) {
this.setFormErrors(error.error)
return EMPTY;
}
else {
// if there is another type of error, throw it again.
throw error;
}
}),
catchError(() => {
// display a notification when other errors occur
this.notification.display('An unexpected error has occurred. Please try again later.')
return EMPTY;
})
)
.subscribe(...)
}

Instead, I can adopt a more elegant solution by encapsulating the extracted logic into a reusable custom operator. This leverages the power of RxJS operator composition, where we can combine existing operators to create new ones with specialized behavior.

In this case, the tapValidationErrors operator is essentially a higher-order function that takes a callback function as an argument and returns a new RxJS operator based on catchError. This custom operator handles validation errors in a controlled and informative manner, allowing us to perform specific actions like displaying error messages while leaving other error types to be handled elsewhere in the pipeline.

The callback parameter in the operator definition will be invoked when these errors are detected. In the component method saveUserData() I specify the exact action to take when validation errors are received from the server, which in this instance is to display the errors in the form.

Error handling strategies in catchError

There's one crucial aspect in the implementation I want to discuss: the use of return EMPTY and throw error. The catchError operator requires that its inner callback either returns an observable or throws an exception.

By returning EMPTY, I explicitly indicate that this error in the stream has been handled within this operator. This prevents the error from propagating further down the observable chain and triggering another error handler, which is not what I expect to happen. EMPTY is a special observable that immediately completes without emitting any values. By returning this observable, we effectively terminate the current observable stream. This is important because, in the context of a form submission, we don't want to continue processing the response if the server indicates validation errors.

On the other hand, by using throw error, I'm explicitly re-throwing errors that are not validation errors. This allows these errors to be caught and handled by a higher-level catchError operator in our RxJS pipeline or by a global error handler in the application (like the ErrorHandler injection token or an HTTP interceptor).

Tracking upload progress: the tapUploadProgress operator

I'll continue to refine the file upload handling by addressing the calculation of upload progress. Currently, this logic resides in the observer in the saveUserData() method, which, as I discussed earlier, isn't ideal. My goal is to create a separate operator that handles this task exclusively. Building upon the approach I've taken with the tapValidationErrors operator, I'll create a new file tap-upload-progress.ts in the same folder. I'll name the operator tapUploadProgress. Here is the content of the file:

/**
* A custom RxJS operator that taps into the HTTP request observable to extract and report upload progress.
*
* @param callback A function to be called whenever an upload progress event is received,
* providing the current progress percentage as an argument.
*
* @returns An RxJS operator function that taps into the observable stream,
* extracts upload progress events, and invokes the provided callback with the calculated progress.
*/
export function tapUploadProgress<T>( callback: ( progress: number ) => void ) {
/**
* Tap into the observable stream to process HttpEvent objects.
* The generic type parameter "T" allows this operator to work with any type of HTTP response data.
*
* @param event An HttpEvent object representing an event in the HTTP request/response lifecycle.
*/
return tap((event: HttpEvent<T>) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
// Calculate and emit the upload progress percentage
const progress = Math.round((100 * event.loaded) / event.total);
callback(progress);
}
});
}
I've removed the extracted code from the saveUserData() method, and added the new operator:
protected saveUserData() {
// set the saving flag
this.isSaveRequestInProgress = true;
this.saveUserData$()
.pipe(
// existing code ...
tapUploadProgress(progress => {
this.uploadProgress = progress;
}),
catchError() // existing code ...
)
.subscribe((event) => {
if (event.type === HttpEventType.Response) {
// store the user data
this.userData = event.body!.data
// update the form with the values received from the server
this.restoreForm();
// display a success notification
this.notification.display('The profile was successfully saved');
}
})
}

By extracting the progress calculation into the tapUploadProgress operator, I've decluttered the saveUserData() method and made it more focused on its core responsibilities. This enhances code readability and maintainability while promoting reusability of the progress-tracking logic across other parts of our application.

Configuring upload progress tracking

In order to access upload progress information, we need to configure the HTTP request with two specific options: reportProgress: true and observe: 'events':

private saveUserData$() {
return this.httpClient.put<UserDataResponse>(
`http://localhost:3000/users/1`,
this.getFormData(),
{
reportProgress: true,
observe: 'events',
})
}

In HttpClient, methods like put, post, get, etc., accept an optional third argument called options. This argument is an object that allows us to configure various aspects of the HTTP request and how the response is handled.

With both reportProgress: true and observe: 'events', we get an observable that emits a stream of HttpEvent objects. Each event represents a different stage of the HTTP request/response lifecycle.

reportProgress: true

This option tells HttpClient to track the progress of the HTTP request, particularly relevant for uploads and downloads.

When reportProgress is true, HttpClient emits HttpEventType.UploadProgress (for uploads) or HttpEventType.DownloadProgress (for downloads) events as part of the observable stream. These events contain information about the progress, such as loaded bytes and total bytes.

In the context of our image upload component, this allows us to track the upload progress and provide feedback to the user through the progress bar indicator.

Note: Even without this option, the code will still work fine, but we'll have no progress indicator. The if condition in the tapUploadProgress operator won't be satisfied, and the callback for updating the progress won't be invoked.

observe: 'events'

This option instructs HttpClient to emit the full sequence of HTTP events instead of just the final response body.

The emitted events can include:

  • HttpEventType.Sent: The request has been sent to the server.
  • HttpEventType.UploadProgress (for uploads): Provides progress information.
  • HttpEventType.Response: The response has been received from the server (this contains the data we usually work with).

By observing events, we gain access to more granular information about the HTTP request lifecycle. In our case, we're using it to access the UploadProgress events to track progress and the Response event to get the final server response.

Extracting the response body with the tapResponseBody operator

Let's make our code even better by introducing another handy tool: the tapResponseBody operator. This operator helps us grab the data we want from successful responses to our HTTP requests.

The code

Here is the content of the tap-response-body.ts file:

/**
* A custom RxJS operator that taps into the HTTP response observable to extract and process the response body.
*
* @param callback A function to be called when a response event with a body is received.
* This function receives the response body of type `T` as an argument.
* @returns An RxJS operator function that taps into the observable stream, extracts response bodies from
* HttpEvents of type HttpResponse, and invokes the provided callback with the extracted body.
*/
export function tapResponseBody<T>(callback: ( body: T ) => void) {
/**
* Taps into the observable stream to process HttpEvent objects.
* The generic type parameter "T" allows this operator to work with any type of HTTP response data.
*
* @param event An HttpEvent object representing an event in the HTTP request/response lifecycle.
*/
return tap((event: HttpEvent<T>) => {
// Checks if the event is a response event (HttpEventType.Response).
if (event.type === HttpEventType.Response) {
// If so, it extracts the response body (which is of type T) and passes it to the callback function.
callback(event.body as T);
}
});
}
And the updated saveUserData() method:
protected saveUserData() {
// set the saving flag
this.isSaveRequestInProgress = true;
this.saveUserData$()
.pipe(
finalize(...),
tapResponseBody(body => {
// store the user data
this.userData = body!.data
// update the form with the values received from the server
this.restoreForm();
// display a success notification
this.notification.display('The profile was successfully saved');
}),
// handle server-side validation errors
tapValidationErrors(...),
tapUploadProgress(...),
catchError(...)
)
.subscribe()
}

Why Use tapResponseBody?

Right now, the saveUserData method handles successful responses directly inside the subscribe block. This works, but it can get messy as our form gets more complex. The tapResponseBody operator cleans things up by separating this logic into a reusable piece.

With tapResponseBody, we can:

  • Separate response handling: Keep the code that deals with the response data away from the main part of the saveUserData method. This makes our code tidier and easier to read.
  • Reuse the logic: Use the same response handling code for other parts of our app where we need to get data from successful responses.
  • Focus on the big picture: Keep the subscribe part simple and focused on the main actions, while the tapResponseBody operator handles the fine details of dealing with the response.

Updating the loadUserData() method

Now that we've successfully applied the tapResponseBody operator for the saveUserData() method, let's see how we can apply it for the loadUserData() method. I'll take a similar approach and move the code from the subscribe method into our operator inner callback:

protected loadUserData() {
// set the loading flag when the request is initiated
this.isLoadRequestInProgress = true;
this.getUserData$()
.pipe(
finalize(() => {
// clear the loading flag when the request completes
this.isLoadRequestInProgress = false;
}),
tapResponseBody(body => {
// clear the loading error flag
this.hasLoadingError = false;
// store the user data
this.userData = body!.data
// display the user data in the form
this.updateForm(this.userData);
}),
catchError((error: HttpErrorResponse) => {
// set the loading error flag
this.hasLoadingError = true;
return EMPTY;
})
)
.subscribe()
}
However, when compiling it, we get a few TypeScript errors:
✘ [ERROR] TS2345: Argument of type 'MonoTypeOperatorFunction<UserProfile>' is not assignable to parameter of type 'OperatorFunction<UserProfile, HttpEvent<unknown>>'.
Type 'Observable<UserProfile>' is not assignable to type 'Observable<HttpEvent<unknown>>'.
Type 'UserProfile' is not assignable to type 'HttpEvent<unknown>'. [plugin angular-compiler]
src/app/user-profile/user-profile.component.ts:111:8:
111 │ finalize(() => {
╵ ~~~~~~~~~~~~~~~~
✘ [ERROR] TS2339: Property 'data' does not exist on type '{}'. [plugin angular-compiler]
src/app/user-profile/user-profile.component.ts:120:32:
120 │ this.userData = body!.data
╵ ~~~~
✘ [ERROR] TS2345: Argument of type 'UserProfile | null' is not assignable to parameter of type 'UserProfile'.
Type 'null' is not assignable to type 'UserProfile'. [plugin angular-compiler]
src/app/user-profile/user-profile.component.ts:123:26:
123 │ this.updateForm(this.userData);
╵ ~~~~~~~~~~~~~
view raw console.sh hosted with ❤ by GitHub

Understanding the challenge

Why doesn't this work smoothly? The problem lies in how our HTTP requests are set up. Let's take a closer look at the two methods:

private getUserData$() {
return this.httpClient.get<UserDataResponse>(`http://localhost:3000/users/1`).pipe(
map(response => response.data)
);
}
private saveUserData$() {
return this.httpClient.put<UserDataResponse>(
`http://localhost:3000/users/1`,
this.getFormData(),
{
reportProgress: true,
observe: 'events',
})
}
There is a key difference. The save request uses observe: 'events', meaning it gives a stream of HttpEvent<UserDataResponse> objects. But getUserData$() doesn't speficy this option, so it defaults to observe: 'body', which gives us the response data directly. Notice the map operator which receives values of type UserDataResponse, but it converts them to UserProfile.

Our tapResponseBody operator is designed to work with HttpEvent objects. This mismatch is causing these TypeScript errors.

At a high level, I see primarily two ways to address this issue:

  1. to modify getUserData$(), or,
  2. to adapt the tapResponseBody operator.

Modify the getUserData$() stream

I could change the GET request to also use observe: 'events', just like saveUserData$(). This would make both requests consistent, and tapResponseBody would work as is. However, this would also make our operator less flexible; it would only work with observables that emit HttpEvent objects.

Here is how this solution could be implemented:

private getUserData$() {
return this.httpClient.get<UserDataResponse>(
`http://localhost:3000/users/1`,
{
observe: 'events',
}
);
}

This might be simpler if we only have a few places where we need to handle both HttpEvent and response body types. On the other hand, we might need to repeat code if we use this pattern in multiple places, and the tapResponseBody operator would be less reusable.

Adapt the tapResponseBody operator

This solution implies the operator to work with requests no matter the value of the observe option, which could be one of: "body", "response", "events". This would make the operator more versatile and adaptable to different HTTP request configurations. It also offers more flexibility and reusability, especially if we have multiple observables that emit either event or response body types. It also encapsulates the type-handling logic within the operator itself. Of course, the tradeoff is that it requires more development work for adapting the logic inside the operator.

I'll choose the more flexible route and enhance the operator to handle both scenarios. For improved clarity, I'll rename it to tapResponseData. Here's the updated code:

/**
* A custom RxJS operator that taps into an HTTP response observable to extract the data payload.
* It handles different response types ('body', 'response', 'events') and ensures a successful response format.
*
* @template T The type of the data payload expected in the successful response.
* @param callback A function that is called with the extracted data payload when a successful response is received.
* @returns An RxJS operator function that can be applied to an observable of HttpEvent<ApiSuccessResponse<T>>, ApiSuccessResponse<T>, or T.
*/
export function tapResponseData<T extends ApiSuccessResponse<any>>(callback: (data: T['data']) => void) {
return tap((value: HttpClientResponse<T>) => {
if (isResponseInstance<T>(value as HttpResponse<T>)) {
callback((value as HttpResponse<T>).body!.data);
}
else if (isPlainResponse<T>(value as T)) {
callback((value as T).data);
}
});
}
/**
* Checks if the emitted value from the HTTP response observable is an instance of `HttpResponse`
* and contains a successful API response body.
*
* This function is specifically designed to handle responses when the `observe` option in `HttpClient`
* is set to 'response' or 'events', where the response is wrapped in an `HttpEvent` object.
*
* @template T The type of the data payload expected in the successful response.
* @param value The emitted value from the HTTP response observable.
* @returns `true` if the value is an `HttpResponse` containing a successful response body, otherwise `false`.
*/
function isResponseInstance<T extends ApiSuccessResponse<any>>(value: HttpClientResponse<T>): value is HttpResponse<T> {
return (value instanceof HttpResponse)
&& isPlainResponse<T>(value.body!)
}
/**
* Checks if a value is a plain object and represents a successful API response.
*
* This function verifies if the value has the correct structure of an `ApiSuccessResponse`,
* including the `data` property and a `status` of 'ok'.
*
* @template T The type of the data payload expected in the successful response.
* @param response The value to check.
* @returns `true` if the value is a plain object representing a successful API response, otherwise `false`.
*/
function isPlainResponse<T extends ApiSuccessResponse<any>>(response: HttpClientResponse<T>): response is T {
return !!response
&& isPlainObject(response)
&& 'data' in response
&& response.status === 'ok'
}
Take a moment to read the comments in the code – they explain how I've improved the operator to handle different types of responses.

The generic type <T> in the operator now represents the specific type of data we expect from the response, UserDataResponse.

Additionally, I've created new files with different data types and interfaces:

/**
* Represents a successful API response with a standardized structure.
* @template T The type of the data payload in the response.
*/
export interface ApiSuccessResponse<T> {
status: 'ok'; // Standard success status
data: T; // The actual data payload
}
/**
* Represents a validation error response from the API.
*/
export interface ValidationError {
field: string; // Name of the field with the error
message: string; // User-friendly error message
code: string; // Error code
}
/**
* Represents the entire validation error response from the API.
*/
export interface ApiValidationErrorResponse {
message: string; // Overall error message
errors: ValidationError[]; // Array of individual field errors
}
/**
* Union type for the possible values emitted by HttpClient observables,
* depending on the 'observe' option used.
* @template T The expected type of the response body.
*/
export type HttpClientResponse<T> = HttpEvent<T> | T | HttpResponse<T>

Here is the updated component where the operator is used:

type UserDataResponse = ApiSuccessResponse<UserProfile>;
protected loadUserData() {
// set the loading flag when the request is initiated
this.isLoadRequestInProgress = true;
this.getUserData$()
.pipe(
finalize(() => {
// clear the loading flag when the request completes
this.isLoadRequestInProgress = false;
}),
tapResponseData(data => {
// clear the loading error flag
this.hasLoadingError = false;
// store the user data
this.userData = data
// display the user data in the form
this.updateForm(this.userData);
}),
catchError(() => {
// set the loading error flag
this.hasLoadingError = true;
return EMPTY;
})
)
.subscribe()
}
private getUserData$() {
return this.httpClient.get<UserDataResponse>(
`http://localhost:3000/users/1`
);
}

Since tapResponseData now handles different kinds of responses, the other two operators tapValidationErrors and tapUploadProgress need to be updated too. The fix is to specify the new type HttpClientResponse<T>, instead of the old oneHttpEvent<T>, which was available only when the observe option was set to 'events'. Here are their updated code:

export function tapUploadProgress<T>( callback: ( progress: number ) => void ) {
return tap((value: HttpClientResponse<T>) => {
const event = value as HttpEvent<T>;
if (isPlainObject(event) && event.type === HttpEventType.UploadProgress && event.total) {
.... (the rest of the code)
}
});
}
export function tapValidationErrors<T>(callback: (error: HttpErrorResponse ) => void ): OperatorFunction<HttpClientResponse<T>, HttpClientResponse<T>> {
return .... (the rest of the code)
}

You can test the code with different settings for the observe option in both load and save requests. The code should successfully handle all scenarios.

Error handling made easy: the tapError operator

To improve the error handling and make the code more compact, I'll introduce a new custom RxJS operator called tapError. This operator will serve as a dedicated mechanism for handling errors in HTTP request streams, like replacing the catchError block in the loadUserData() or saveUserData() methods.

The purpose of tapError

The primary goal of the tapError operator is to execute specific actions when an error occurs within an observable stream. In our case, the loadUserData() method needs to be notified when an HTTP error happens so we can set an error flag in the UI.

Implementation

Here's the implementation of the operator:

/**
* A custom RxJS operator that taps into the observable stream to handle HTTP errors.
* It invokes a callback function with the error and then completes the stream,
* preventing further emissions.
*
* @param callback A function to be called when an HTTP error occurs.
* This function receives the HttpErrorResponse object.
*
* @returns An RxJS operator function that catches HTTP errors, calls the callback function,
* and then returns EMPTY to complete the stream, preventing subsequent values from being emitted.
*/
export function tapError(callback: (error: HttpErrorResponse) => void) {
/**
* Catches errors in the observable stream and handles them gracefully.
*/
return catchError((error: HttpErrorResponse) => {
// Invoke the callback to handle the error (e.g., log, display message)
callback(error);
/**
* Returns EMPTY to complete the observable stream.
* This prevents any further values from being emitted after the error has been handled.
* This is useful for scenarios where we want to stop processing after an error occurs (e.g., in HTTP requests).
*/
return EMPTY;
})
}
view raw tap-error.ts hosted with ❤ by GitHub
The usage in the component is straightforward:
protected loadUserData() {
// ... existing code
this.getUserData$()
.pipe(
finalize(...),
tapResponseData(...),
tapError(() => {
// set the loading error flag
this.hasLoadingError = true;
})
)
.subscribe()
}
protected saveUserData() {
// ... existing code
this.saveUserData$()
.pipe(
finalize(...),
tapResponseData(...),
tapValidationErrors(...),
tapUploadProgress(...),
tapError(() => {
// display a notification when other errors occur
this.notification.display('An unexpected error has occurred. Please try again later.')
})
)
.subscribe()
}

One important distinction: the tapError operator is designed for single use within a stream. Why? Because the stream will terminate immediately after the operator handles the error.

Error handling strategies in RxJS

There are several ways to respond to errors in RxJS streams:

  • catchError operator: this is the most common and flexible way to handle errors. It allows us to catch errors and decide how to proceed, either by returning a new observable, emitting a fallback value, or throwing the error again.
  • tap operator with error callback: executes a side effect when an error occurs but allows the error to propagate further.
  • subscribe method's error callback: Handles the error at the end of the observable chain.

Why catchError is the right tool

Here is an alternative implementation of the tapError with the tap operator and the error callback:

export function tapError(callback: (error: HttpErrorResponse) => void) {
return tap({
error: (error: HttpErrorResponse) => {
callback(error);
}
})
}
view raw tap-error.ts hosted with ❤ by GitHub
If you use this implementation, the behavior of the loadUserData() would remain the same.

In our scenario, I'm interested in both handling the error (setting the hasLoadingError flag) and stopping the error from propagating. This aligns perfectly with the purpose of catchError. Here's why tap with the error callback wouldn't be ideal:

  • uncontrolled error propagation: The tap operator doesn't stop errors from continuing down the stream. This means the error would still reach the subscribe block error callback, potentially causing duplicate error handling and unexpected behavior.
  • limited control: While tap allows us to perform actions in response to errors, it doesn't let us change the stream's behavior fundamentally. In our case, we want to stop the stream after an error, which tap can't do.

By using catchError with return EMPTY, we achieve clear error handling and explicit stream termination.

Conclusion: A cleaner, more maintainable approach

In this article, I've shown you how custom RxJS operators can make the Angular code much cleaner and easier to work with. I created special operators like tapValidationErrors, tapUploadProgress, tapResponseData and tapError to handle different parts of HTTP requests.

By using these custom operators, we've made our code:

  • easier to understand - each operator does one specific job, making it simpler to read and follow the logic.
  • reusable - we can use these operators in other parts of the project, saving us time and effort.
  • more flexible - we can now easily change how we handle errors or responses without affecting other parts of the code.

Feel free to explore and experiment with the code from this article, available in the 15.http-rxjs-operators branch of the repository: https://github.com/cezar-plescan/user-profile-editor/tree/15.http-rxjs-operators.

I hope this article helps you see how awesome custom RxJS operators are. Feel free to use these ideas in your own Angular projects and let me know if you have any questions or comments. Let's keep learning and improving together as Angular developers.

Thanks for reading!

Retry later

Top comments (0)

Retry later
Retry later