Building Custom RxJS Operators for HTTP Requests
Cezar Pleșcan
Posted on June 14, 2024
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
, andtapError
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:
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:
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:
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 thetapResponseBody
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:
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:
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:
- to modify
getUserData$()
, or, - 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:
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:
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 witherror
callback: executes a side effect when an error occurs but allows the error to propagate further. -
subscribe
method'serror
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:
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 thesubscribe
blockerror
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, whichtap
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!
Posted on June 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.