I Migrated an Angular Application to Signals Then Back to RxJs

Introduction
Disclamer: This article is translated from Croatian into English via AI. I proofread some of the text, but there may still be some errors. The original article can be found here.
In October of last year, I started working on a new project, the largest project I’ve participated in so far. I joined the team in the early phase, and at that stage, we had the opportunity to experiment with different technologies and approaches.
The backend of the application was built in SpringBoot, which generates an OpenAPI specification.
When I joined the team, the frontend application already had client library generation from the OpenAPI specification configured. The client library was generated using openapi-generator with the Angular adapter.
The generated code consisted of services named after tags from the OpenAPI specification (e.g., UserService, OrderService, …).
These services contain methods that correspond to HTTP endpoints from the specification (e.g., getUserById
, createOrder
, …)
and return Observable objects.
I’m not the biggest fan of Angular, but I liked that Angular maintainers are trying to introduce some modern concepts into Angular applications - primarily signals.
It seemed to me that Angular was moving in the direction of distancing itself from RxJs and towards signals (like various other frameworks lately, e.g., Svelte, SolidJs, Preact, etc.). So I explored the possibilities of working with HTTP requests using signals instead of RxJs.
Resources
Around that time, Angular released v19
which introduced resources.
As a big fan of SolidJs, I immediately noticed the similarities with the createResource
function.
Resources accept a dependency list of signals and a loader function that expects a Promise
as a result and returns a Resource
object
which contains value
, error
, and isLoading
signals, and a very useful reload
function.
import {resource, Signal} from '@angular/core';
const userId: Signal<string> = getUserId();
const userResource = resource({
// Define a reactive request computation.
// The request value recomputes whenever any read signals change.
request: () => ({id: userId()}),
// Define an async loader that retrieves data.
// The resource calls this function every time the `request` value changes.
loader: ({request}) => fetchUser(request),
});
// Create a computed signal based on the result of the resource's loader function.
const firstName = computed(() => userResource.value().firstName);
There’s also an rxResource
utility function that accepts an Observable
instead of a Promise
in the loader
function.
A very convenient RxJs
interop.
Two major problems I had with this approach were:
- it was only an experimental feature
- the story around actions/mutations was non-existent
The fact that it was only an experimental feature didn’t bother me too much. I had used signals in Angular while they were experimental, and their API changed minimally. Plus, with Typescript, I’m pretty confident I could fix all the necessary changes in a few hours if needed.
However, the story around actions/mutations was problematic. Angular resources focus only on loading data, not manipulating it. Which would mean I would have to implement my own action/mutation system or mix resources and RxJs.
Plus, there’s also the problem of automatically generating a client library from the OpenAPI specification, because
there isn’t yet an adapter for Angular resources (as far as I know).
I could use the generated services that return Observables and wrap each call using
rxResource
, but I think that would be too much overhead.
Tanstack Query
I’ve used Tanstack Query in React, SolidJs, and also in Angular. I’m well acquainted with it and I really like their approach. The core library is framework-agnostic and the API is almost identical across all frameworks.
I found a way to generate Tanstack query helpers from the OpenAPI specification, tested how it works and how it feels. I was very satisfied because the API was very simple and intuitive.
const userId = signal(123);
const userQuery = injectQuery(() => ({
...getUserByIdOptions({
path: {
id: userId()
}
})
}))
userQuery.data(); // Retrieves the data
userQuery.isPending(); // Checks if the request is in progress
userQuery.error(); // Retrieves the error
Super simple and intuitive.
I showed it to colleagues and convinced them to try using Tanstack Query for two weeks. If the majority didn’t like it, I would revert the application to its previous state. We were in a pretty early phase of the project, so we could afford such an experiment.
That’s when I wrote the article Why is the HTTP Layer in Your Angular Application Bad? where I explained why I think Tanstack Query is a better approach than RxJs.
On the same topic, I held a workshop where I showed my team colleagues, and others who were interested, how to use Tanstack Query in Angular applications.
At the workshop, some expressed concerns about the Angular adapter for Tanstack Query still having experimental
status.
But we got permission to experiment with it for a while longer.
We worked with Tanstack Query for three months, and finally, the time came to decide whether we would continue using Tanstack Query or return to RxJs.
Return to RxJs
Concerns with the Current Approach
The two biggest problems causing concern in the team were:
- the
experimental
status of the adapter - moving away from Angular’s standard approach
I honestly hoped that during that period, the Tanstack Query adapter for Angular would drop its experimental
status,
but that hasn’t happened yet. Despite that status, the API remained stable with no breaking changes.
However, moving away from Angular’s standard approach was a bigger problem.
I completely understand the concern. You have a team of Angular developers who are used to one approach, and then I come in and suggest something completely different. You want to enable a new developer joining the team to quickly start working on the project.
Adding another layer of abstraction, especially on such an important part of the application as the HTTP layer, can be problematic. It means every new developer has to learn a new approach. Also, it means you have an older application written with one approach, and new code written with another approach.
Additionally, there was concern about maintaining the Tanstack Query adapter for Angular. In my opinion, the Tanstack team is very good, and I believe they would take care of the adapter, but I understand the concern.
Eventually, the decision came - we would return to RxJs.
Refactoring
Since I was the one who introduced Tanstack Query to the project, I decided to take responsibility for refactoring. Also, I wanted to set examples for each use case of Tanstack Query to make further development easier for my colleagues.
The plan was to do the refactoring by moving as much code as possible to RxJs, but not necessarily the entire application. The more code I move to RxJs, the better, but I needed to at least set an example for each use case we had with Tanstack Query. So if I didn’t manage to migrate the entire application, colleagues could continue with the refactoring relying on those examples.
Our sprint ended on Friday, so I had a perfect opportunity for refactoring.
The repository was pretty clean because everyone on the team was aware that major refactoring was coming,
but also since it was the end of the sprint, most had finished their current tasks and everything was merged into the main
branch.
I managed to refactor the entire application over the weekend, which felt really good, and completely remove Tanstack Query from the application.
I wasn’t satisfied with this refactoring because I consider it a step backward.
What I Miss from Tanstack Query
Everything I’ll list are things I could implement myself, but then I’d be reinventing the wheel. I’ve already explained in the mentioned article why I think Tanstack Query is a better approach than RxJs. These are the things I miss most from Tanstack Query:
1. Signal-based dependency tracking
As Angular moves more towards signals, it’s logical that we should follow that direction too. The recommendation for maintaining a reactive piece of state is to put it in a signal. That signal can be used in the template, it’s easy to write to it, and it’s easy to create a new signal that depends on it.
Since Tanstack Query is signal-based, it was very easy to track dependencies between signals.
const userId = signal(123);
const userQuery = injectQuery(() => ({
...getUserByIdOptions({
path: {
id: userId()
}
})
}))
If userId
changes, userQuery
will automatically refetch.
If we rewrite this example in RxJs, it would look like this:
// Version 1
const userId = signal(123);
const userId$ = toObservable(userId);
// Version 2
const userId$ = new BehaviorSubject(123);
const user$ = userId$.pipe(
switchMap(id => this.userService.getUserById(id))
)
This change effectively means that a bunch of state that was in signals now has to be in BehaviorSubjects - a step backwards.
2. Automatic error tracking
Tanstack Query automatically tracks errors and loading state.
When an error occurs, the error is automatically stored in the error
signal.
This can of course be implemented with RxJs as well, but it’s a lot more boilerplate code.
const userId$ = new BehaviorSubject(123);
const error$ = new BehaviorSubject(null);
const loading$ = new BehaviorSubject(false);
const user$ = userId$.pipe(
tap(() => loading$.next(true)),
switchMap(id => this.userService.getUserById(id).pipe(
catchError(err => {
error$.next(err);
return of(null);
}),
finalize(() => loading$.next(false))
))
)
Of course, we can make some wrapper around this, or even a custom pipe
operator, but that’s additional overhead - reinventing the wheel.
3. Infinite queries
This is the feature I miss the most.
Using Tanstack Query’s injectInfiniteQuery
, we can very easily create a resource that:
- resets state depending on the dependency list
- has the ability to fetch the next and/or previous page
- automatically tracks loading and errors
- tracks loading states by pages
- aggregates data from all pages
const userId = signal(123);
const friendsQuery = injectInfiniteQuery(() => ({
...getFriendsOptions({
path: {
id: userId()
}
}),
getNextPage: (page) => {...},
initialPageParam: 0
});
friendsQuery.data(); // List of pages that need to be flatMap-ed
friendsQuery.isPending(); // Checks if the request is in progress
friendsQuery.isFetchingNextPage(); // Checks if the request for the next page is in progress
friendsQuery.hasNextPage(); // Checks if there are more pages
// and most importantly
friendsQuery.fetchNextPage(); // Fetches the next page
A very, very useful feature.
I had to implement this myself using RxJs. I probably created several bugs there, but I had no other options. This kind of API is very important to me because it’s quite useful.
4. Stale-while-revalidate
A very neat feature is the stale-while-revalidate
strategy.
This strategy returns data from the cache and fetches new data in the background. When new data is fetched, the old data is replaced with the new.
Example:
- we go to a user’s profile page
- we fetch the user’s data, a loader is displayed
- the user’s data is displayed
- we go to another page
- we return to the user’s profile page
- no loader, old data is displayed, and fresh data is being fetched in the background
Without Tanstack Query
With Tanstack Query
It’s not too important, but quite convenient not having to always show a loader.
With this feature, we can also decide granularly for certain queries and set the time
for how long the data will be stale before new data is fetched.
So, for example, if we have some reference data that rarely changes, we can set staleTime=Infinity
.
Then we won’t have to fetch that data again as long as we’re in the current session.
5. Repeated requests
It can of course be done with RxJs using Angular HTTP interceptors, but it requires much more knowledge about RxJs and retry strategies.
While with Tanstack Query, this can be controlled granularly for each query.
6. Granular query invalidation
This is one of the features I miss the most.
Let’s say, for example, we have a component that displays a user’s first and last name in a card in the header.
Somewhere way down below, we have a component that allows the user to change their first and last name. Upon successful change, we want the first and last name in the header to automatically update.
Using Tanstack Query, we have two ways we could do this:
- invalidate the card query using its
queryKey
- this refetches that query in the background
- set known changes in advance in that query’s cache
With this, we can granularly control what and when gets refetched.
Some things we can update on the view without a request (because, for example, the POST req returns updated data in the response), and for some, it’s simpler to invalidate the cache and refetch the data.
But we have tight control over exactly what we invalidate and when.
Using vanilla Angular and RxJs, we have a few ways to do this (and I don’t like any of them).
- We can have a refresh subject per query that will emit when we want to refetch the query.
- We can have a global refresh subject that will emit when we want to refetch any query.
The first method implies a lot of boilerplate code. Some hardcore “traditional” Angular developers might even create a new service per query or per group of queries (e.g., UserCacheService, OrderCacheService, …).
The second method is better (because it’s simpler), but then we lose granular control over what gets refetched.
Upon successful action, we say globalRefreshSubject.next()
and all queries are refetched.
Then every query that wants to react to the global refresh would have to look something like this:
const globalRefresh$ = new ReplaySubject<void>(1);
const filter$ = new BehaviorSubject(null);
const users$ = combineLatest([filter$, globalRefresh$]).pipe(
switchMap(([filter]) => this.userService.getUsers(filter)),
)
Not necessarily a ReplaySubject
, but something similar (startWith
, BehaviorSubject
, …).
Conclusion
With all its advantages, I believe Angular has fallen behind compared to other modern frameworks. It’s currently in a transitional period, and because of that, it’s a bit “uncomfortable”. Although many new features have been introduced (mostly signals), in many cases, I can’t or don’t want to use them because I don’t want to mix signals and RxJs.
I hope that over the next year, signals will become the primary way to work in Angular. Even if that happens, I think most existing applications will still remain in the world of RxJs, because it’s not worth refactoring, and mixing signals and Observables isn’t the best experience.
Honestly, Angular in its current state is not my primary choice, nor would I recommend it to others. But I don’t mind working with Angular.