DEV Community

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

Posted on • Edited on

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:

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:

I've also updated the saveUserData() method in the user-profile.component.ts file:

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.

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:

I've removed the extracted code from the saveUserData() method, and added the new operator:

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':

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:

And the updated saveUserData() method:

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:

However, when compiling it, we get a few TypeScript errors:

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:

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:

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:

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:

Here is the updated component where the operator is used:

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:

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:

The usage in the component is straightforward:

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:

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!

Top comments (0)