Skip to content

I Migrated an Angular Application to Signals Then Back to RxJs

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:

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:

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:

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:

Without Tanstack Query

NameMarkoLast NameJerkićAge24Favourite HobbitSamwise GamgeeFavourite Football ClubBayern MunichFavourite Fictional CharacterShikamaru Nara

With Tanstack Query

NameMarkoLast NameJerkićAge24Favourite HobbitSamwise GamgeeFavourite Football ClubBayern MunichFavourite Fictional CharacterShikamaru Nara

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:

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).

  1. We can have a refresh subject per query that will emit when we want to refetch the query.
  2. 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.