Skip to content

Why are your Angular HTTP requests bad

Why are your Angular HTTP requests bad

Intro

Disclaimer: This article is translated from Croatian into English via ChatGpt. I proofread some of the text, but there may still be some errors. I will correct them wheh I have time.


I made a workshop on this topic, and you can find the source code here.

If we were to compare Angular to other popular frameworks in 2018, Angular looked like a meta-framework at the time. At that time, the only serious competition was React and Vue, and Angular was the only one that came with a built-in HTTP layer. At that time, Angular had just made a big transition from AngularJS to Angular 2, React had switched to functional components, and Vue 2 was gaining popularity.

Back then, and more or less today, if we wanted to make an HTTP request in React, we would have to use fetch in an effect and set the result in the component state.

function Component() {
    const [data, setData] = useState(null);
    useEffect(() => {
        fetch("https://api.example.com/data")
            .then((response) => response.json())
            .then((data) => setData(data));
    }, []);

    if (!data) return <div>Loading...</div>;
    return <div>{data}</div>;
}

Unlike React, Angular has a built-in HttpClient service that is used to send HTTP requests. Angular’s HttpClient, like the entire Angular ecosystem at the time, uses RxJs to manage asynchronous events.

Asides from the built-in HTTP solution, Angular comes with a lot of other features that React doesn’t have. Angular has its own router, form management solution, HTTP interceptors, and much more. React was promoted as a view layer in the MVC architecture until recently, while Angular was more of a battery-included solution.

Because of this, for the standards of 2018, we can say that Angular was closer to a modern concept of a meta-framework than React or Vue. But if we compare Angular today with modern meta-frameworks like SvelteKit, Remix, or Nuxt, Angular looks outdated.

In this article, I will show the methods I have seen people use to make HTTP requests in Angular and why I think those methods are bad. I will show how I think HTTP requests should ideally be done in vanilla Angular, and finally, I will show why I think vanilla Angular is not enough and how it could be improved.

Example app

First, I will show an example application that I will use to demonstrate all the methods.

Let’s assume we have a PokemonService that looks like this:

interface PokemonService {
    getPokemon(id: number): Observable<Pokemon>;
    getCurrentUser(): Observable<{ user: User; favouritePokemon: Pokemon }>;
    getAllPokemon(): Observable<Pokemon[]>;
    setPokemonAsFavourite(id: number): Observable<void>;
}
Aplikacija

We have an application with a route /pokemon/:id, which has three components:

Variable id in the route is the ID of the pokemon currently being displayed. When the user clicks on View, the route changes to /pokemon/:id where id is the ID of the pokemon the user has chosen. By selecting Mark as favourite, we set the pokemon with the current ID as the user’s favourite. After that, we want to update the UserComponent and PokemonListComponent components to show the new favourite pokemon.

Bad methods

This article will discuss Angular 19 in SPA CSR applications. I believe that Angular is not mature enough for SSR fullstack applications and that there are much better solutions for that.

I will present the bad methods from the worst to the best.

Query Result as a Component Class Property

The first method I will show is the query result as a component class property. This method is the path of least resistance, so it is very often used by beginners in Angular.

In this method, within the ngOnInit method of the component, a function is called that returns the query result. Since HttpClient returns an Observable, to access the query result, we need to subscribe to the Observable. Unlike the async/await API, RxJs uses callback functions to subscribe to the subject. This means that we need to extract the result from the callback function and make that variable accessible through the component’s template. The simplest way to do this is to initialize the result variable as a component class property to null, and then set the query result to that variable in the callback function.

@Component({
    selector: 'app-user',
    template: `
        <div *ngIf="user">
            <h1>{{ user.user.name }}</h1>
            <p>{{ user.favouritePokemon.name }}</p>
        </div>
    `
})
export class UserComponent implements OnInit {
    private pokemonService = inject(PokemonService)

    public user: {user: User, favouritePokemon: Pokemon} | null = null;

    ngOnInit() {
        this.pokemonService.getUser().subscribe(user => this.user = user);
    }
}

@Component({
    selector: 'app-pokemon-list',
    template: `
        <ul>
            <li *ngFor="let pokemon of pokemonList">
                <span>{{ pokemon.name }}</span>
                <a [routerLink]="['/pokemon', pokemon.id]">Pogledaj</a>
            </li>
        </ul>

    `
)}
export class PokemonListComponent implements OnInit {
    private pokemonService = inject(PokemonService)

    public pokemonList: Pokemon[] | null = null;
    ngOnInit() {
        this.pokemonService.getAllPokemon()
            .subscribe(pokemonList => this.pokemonList = pokemonList);
    }
}


@Component({
    selector: 'app-pokemon-detail',
    template: `
        <h1>{{ pokemon.name }}</h1>
        <button (click)="setAsFavourite()">Označi kao omiljenog</button>
    `
})
export class PokemonDetailComponent implements OnInit {
    private pokemonService = inject(PokemonService)
    private route = inject(ActivatedRouteRoute)
    private pokemonId = this.route.snapshot.params.id;

    public pokemon: Pokemon | null = null;
    ngOnInit() {
        this.pokemonService.getPokemon(this.pokemonId)
            .subscribe(pokemon => this.pokemon = pokemon);
    }

    setAsFavourite() {
        this.pokemonService.setPokemonAsFavourite(this.pokemonId)
            .subscribe(() => {
                alert('Pokémon postavljen kao omiljeni');
            });
    }
}

What’s wrong with this? Actually, quite a lot.

Let’s start with the less technical problem. Where are our loading indicators? How does the user know that something is loading? How does the user know that an error has occurred? This is the first problem that arises with this method. To solve this problem, we need to add loading and error indicators alongside each query result.

Loading and error indicators

Let’s take for example UserComponent.

@Component({
    selector: "app-user",
    template: `
        @if (loading) {
            <p>Loading...</p>
        }
        @if (error) {
            <p>Error: {{ error }}</p>
        }
        @if (user) {
            <h1>{{ user.user.name }}</h1>
            <p>{{ user.favouritePokemon.name }}</p>
        }
    `,
})
export class UserComponent implements OnInit {
    private pokemonService = inject(PokemonService);

    public user: { user: User; favouritePokemon: Pokemon } | null = null;
    public loading = true;
    public error = null;

    ngOnInit() {
        this.pokemonService.getUser().subscribe({
            next: (user) => {
                this.user = user;
            },
            error: (error) => {
                this.error = error;
            },
            finalize: () => {
                this.loading = false;
            },
        });
    }
}

This is already better, but still not good enough. We have to handle each query separately, which means that if we have multiple queries in the same component, we need to maintain loading, error, and result variables for each query individually.

Route change

If the user clicks on View in PokemonListComponent, the route changes to /pokemon/:id. Will our implementation of PokemonDetailComponent automatically update to display the new Pokémon? For someone who doesn’t have enough experience with Angular, i.e., with RxJs, it would be very easy to make a mistake here.

We took pokemonId from the current route, but only as a snapshot. This means that if the route changes, pokemonId will not change. Unlike React, Angular does not automatically react to all changes in the component state or context.

So how can we react to route changes? The same service we use to fetch data can also be used to react to route changes.

private route = inject(ActivatedRouteRoute)
private pokemonId$: Observable<number> = this.route.params.pipe(
    map(params => params.id),
    filter(id => !!id),
    map(id => parseInt(id))
);

With this, we have obtained pokemonId$, which is an Observable<number> and emits a new Pokémon ID every time the route changes. Since we no longer have a static Pokémon ID as a class property, we have a new problem. Now we need to react to changes in the Pokémon ID and fetch the new Pokémon every time the ID changes.

This means that we want to have pokemon$: Observable<Pokemon> which depends on pokemonId$: Observable<number>. Now we come to the first significant improvement in our implementation - switchMap.

switchMap is used to trigger the creation of a new Observable by emitting the value of another Observable.

@Component({
    selector: 'app-pokemon-detail',
    template: `
        @if (loading) {
            <p>Loading...</p>
        }
        @if (error) {
            <p>Error: {{ error }}</p>
        }
        @if (pokemon) {
            <h1>{{ pokemon.name }}</h1>
            <button (click)="setAsFavourite()">Označi kao omiljenog</button>
        }
    `
})
export class PokemonDetailComponent implements OnInit {
    private pokemonService = inject(PokemonService)
    private route = inject(ActivatedRouteRoute)
    private pokemonId$: Observable<number> = this.route.params.pipe(
        map(params => params.id),
        filter(id => !!id),
        map(id => parseInt(id));

    public pokemon: Pokemon | null = null;
    public loading = true;
    public error = null;

    ngOnInit() {
        this.pokemonId$.pipe(
            switchMap(id => this.pokemonService.getPokemon(id))
        ).subscribe(() => {
            next: (pokemon) => {
                this.pokemon = pokemon;
            },
            error: (error) => {
                this.error = error;
            },
            finalize: () => {
                this.loading = false;
            }

        });
    }

    setAsFavourite() {
        this.pokemonService.setPokemonAsFavourite(this.pokemonId)
            .subscribe(() => {
                alert('Pokémon postavljen kao omiljeni');
            });
    }
}

PokemonDetailComponent has just become much more complex, but much more robust. We have created a dependency graph of asynchronous events, which means that every time the Pokémon ID changes, a new Pokémon will be automatically fetched. And that’s great for us.

Unwanted Side Effects

It may not seem like it at first, but the last example of PokemonDetailComponent is identical to the React example from the introduction. They are identical in that both examples create a side effect.

A side effect in the context of a web framework is an event that is triggered as a reaction to another event without resulting in the emission of a value into a state store.

Reactive processes can be reduced to three basic components:

Computed state is the state that is calculated every time the state it depends on changes. The difference between side effects and computed state is that a side effect does not result in a state change, while computed state results in a state change.

The example from PokemonDetailComponent is a side effect because in the next, error, and finalize functions we perform some function when we receive an event from the pipe. pokemonId$ is computed state because every time the route changes, a new Pokémon ID is calculated and saved into a new state without exiting the context of the callback function.

This example is an anti-pattern because we are affecting the state outside the context of the callback function by setting this.pokemon, this.loading, and this.error. Such causes result in the notorious ExpressionChangedAfterItHasBeenChecked error.

One additional and invisible problem is that we subscribed to pokemonId$ in the ngOnInit method but did not unsubscribe. This means that every time the component is created, a new subscription to pokemonId$ is created, but it is never unsubscribed. This can result in a memory leak and unnecessary load on the application.

How to solve this problem?

If we rely on RxJs to manage asynchronous events, then we should fully rely on the RxJs way of programming. This means that every expected asynchronous event should be viewed as a pipe through which information flows like water.

A side effect in that pipe is that we left the pipe open so we can observe what is happening in the pipe and react to it.

Wouldn’t it be better if instead of an open pipe, we had a closed pipe that branches or merges with other pipes as needed? Without potential water leakage?

If we go back to the context of Angular and RxJs, this would mean that instead of manually subscribing to a subject, we save the query results as an Observable property of the component class. And we subscribe to that Observable in the template using the async pipe.

@Component({
    selector: 'app-pokemon-detail',
    template: `
        @if (loading$ | async) {
            <p>Loading...</p>
        }
        @if (error$ | async; as error) {
            <p>Error: {{ error }}</p>
        }
        @if (pokemon$ | async; as pokemon) {
            <h1>{{ pokemon.name }}</h1>
            <button (click)="setAsFavourite(pokemon)">Označi kao omiljenog</button>
        }
    `,
    imports: [AsyncPipe]
})
export const PokemonDetailComponent {
    private pokemonService = inject(PokemonService)
    private route = inject(ActivatedRouteRoute)
    private pokemonId$: Observable<number> = this.route.params.pipe(
        map(params => params.id),
        filter(id => !!id),
        map(id => parseInt(id));

    public loading$ = new BehaviorSubject<boolean>(true);
    public error$ = new BehaviorSubject<Error | null>(null);
    public pokemon$: Observable<Pokemon> = this.pokemonId$.pipe(
        switchMap(id => {
            this.loading$.next(true);
            this.error$.next(null);
            return this.pokemonService.getPokemon(id).pipe(
                catchError(error => {
                    this.error$.next(error);
                    return EMPTY;
                }),
                finalize(() => this.loading$.next(false))
            );
        })
    );

    setAsFavourite(pokemon: Pokemon) {
        this.pokemonService.setPokemonAsFavourite(pokemon.id)
            .pipe(first()).subscribe();
            .subscribe(() => {
                alert('Pokémon postavljen kao omiljeni');
            });
    }
}

What did we achieve with this solution?

We have a component that correctly fetches a new Pokémon when the route changes, saves loading and error states, and displays them in the template without us having to worry about subscriptions and the component’s lifecycle.

Strictly speaking, we still have side effects in the switchMap function because we exit the context of the function and set loading$ and error$ states. But at least now we have reduced the risk of the ExpressionChangedAfterItHasBeenChecked error because we have put all state changes into Subjects that we can subscribe to in the template using the async pipe.

Why is this still not enough?

In this solution, I see two more problems:

1. Outdated Reactivity System

Since Angular introduced signals in version 16 as an alternative reactivity system meant to replace RxJs and Zone.js, with each new version of Angular, more and more core functionalities are being moved from RxJs to signals.

RxJs and Zone.js are independent libraries that Angular has long relied on. But these libraries are general-purpose. RxJs, for example, has a huge amount of functionality that is, for the most part, unnecessary for most web applications. By using RxJs as a reactivity system, the developer is forced to learn RxJs in addition to Angular. Angular is already a large and complex framework, making learning RxJs seem like an unnecessary burden.

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

In version 19, Angular introduced resource as an alternative to the HttpClient service. Inspired by SolidJs’s resource, Angular’s resource accepts an async function from which it creates a resource signal with the result of the async function along with loading and error indicators.

The only problem is that resource is still an experimental feature and is not recommended for use in production applications.

2. State Invalidation

This issue is somewhat less technical in nature but perhaps more important than the previous one.

Let’s return to the example of our application.

Application

When a user clicks on Mark as Favorite in the PokemonDetailComponent, we send a request to the server to set the current ID’s Pokemon as a favorite. After successfully changing the favorite, UserComponent and PokemonListComponent will retain an incorrect state. That is, they will have the state they loaded at the time of component creation. This is not good. We want to somehow invalidate the state of the components after the state changes on the server.

If we look at modern meta-frameworks like SvelteKit and Remix, they have built-in state invalidation mechanisms. These two meta-frameworks have concepts of loader and action that are used for fetching data and changing state on the server. After a successful action call, the state obtained through the loader is automatically invalidated and the components re-render.

In our Angular application, we have to take care of this ourselves.

Several possible solutions come to mind:

  1. Global Event Bus
    • Create a global service that contains a ReplaySubject<void> which we trigger through an HttpInterceptor after a successful HTTP POST, PUT, or DELETE request.
    • This means that before each HttpClient call, we must first reference the invalidate$ subject, and in the switchMap, call the HttpClient service.
    • This solution is quite complex and not recommended for larger applications.
    • Also, this solution works automatically for all requests, which means that some components might be invalidated even if it’s not necessary.
  2. Localized Event Bus
    • Similar to the global event bus, but instead of a global service, we use a localized service that is used only in components that need invalidation.
    • This solution has pros and cons compared to the global event bus but is certainly more complicated than the global event bus.

Neither of these solutions is ideal. Both solutions are complicated.

Tanner Linsley to the Rescue

Since React did not have a built-in solution for HTTP requests from the beginning, the React community had to find a solution for this problem.

Tanner Linsley then created React Query as a solution for managing asynchronous events in React. This solution became extremely popular. Most serious React applications, unless heavily relying on some meta-framework like Remix, use React Query.

At some point, Tanner realized that his solutions were not only applicable to React but to all modern web frameworks. Thus, React Query became Tanstack Query, and React got an adapter for Tanstack Query. Soon, adapters for Vue, Svelte, and SolidJs were created, but Angular was without an adapter for a long time.

That was until Angular introduced signals as a new reactivity primitive. The Angular community immediately started working on an adapter for Tanstack Query that would rely on signals. Thus, the Angular Tanstack adapter was born.

I won’t go into details about why Tanstack Query is very good, as that topic has already been covered many times. For those interested in more, I recommend TkDoto’s blog. His examples refer to the React adapter, but the API is 90% the same. Instead of useQuery, we use injestQuery, and instead of useMutation, we use injestMutation. The rest is the same. The Tanstack Query core API is used under the hood.

The point of using Tanstack Query is that instead of creating our own global event bus and recreating Redux, we use a library trusted by thousands of production applications that has already gone through all possible edge cases. If you think you know better than the entire front-end developer community working on this solution for the past 5 years, then go ahead.

I recognize that I don’t have nearly enough experience to create something better than Tanstack Query, so I’ll rely on people who know what they’re doing 😂.

Out Comonents with Tanstack Query

@Component({
    selector: 'app-user',
    template: `
        @if (userQuery.isLoading()) {
            <p>Loading...</p>
        }
        @if (userQuery.isError()) {
            <p>Error: {{ userQuery.error() }}</p>
        }
        @if (userQuery.isSuccess()) {
            <h1>{{ userQuery.data().user.name }}</h1>
            <p>{{ userQuery.data().favouritePokemon.name }}</p>
        }
    `
})
export const UserComponent {
    private pokemonService = inject(PokemonService)
    private queryClient = injectQueryClient();

    public userQuery = injectQuery(() => ({
        queryKey: ['user'],
        queryFn: () => lastValueFrom(this.pokemonService.getCurrentUser()),
    }))
}

@Component({
    selector: 'app-pokemon-list',
    template: `
        @if (pokemonListQuery.isLoading()) {
            <p>Loading...</p>
        }
        @if (pokemonListQuery.isError()) {
            <p>Error: {{ pokemonListQuery.error() }}</p>
        }
        @if (pokemonListQuery.isSuccess()) {
            <ul>
                <li *ngFor="let pokemon of pokemonListQuery.data()">
                    <span>{{ pokemon.name }}</span>
                    <a [routerLink]="['/pokemon', pokemon.id]">Pogledaj</a>
                </li>
            </ul>
        }
    `
})
export const PokemonListComponent {
    private pokemonService = inject(PokemonService)
    private queryClient = injectQueryClient();

    public pokemonListQuery = injectQuery(() => ({
        queryKey: ['pokemon', 'list'],
        queryFn: () => lastValueFrom(this.pokemonService.getAllPokemon()),
    }))
}

@Component({
    selector: 'app-pokemon-detail',
    template: `
        @if (pokemonQuery.isLoading()) {
            <p>Loading...</p>
        }
        @if (pokemonQuery.isError()) {
            <p>Error: {{ pokemonQuery.error() }}</p>
        }
        @if (pokemonQuery.isSuccess()) {
            <h1>{{ pokemonQuery.data().name }}</h1>
            <button (click)="setAsFavourite(pokemonQuery.data())">Označi kao omiljenog</button>
        }
    `
})
export const PokemonDetailComponent {
    private pokemonService = inject(PokemonService)
    private queryClient = injectQueryClient();
    private pokemonId: Signal<number> = injectParam<number>('id');

    public pokemonQuery = injectQuery(() => ({
        queryKey: ['pokemon', this.pokemonId()],
        queryFn: () => lastValueFrom(this.pokemonService.getPokemon(this.pokemonId)),
    }))
    public favouriteMutation = injectMutation(() => ({
        mutationKey: ['favourite'],
        mutationFn: (pokemon: Pokemon) => lastValueFrom(this.pokemonService.setPokemonAsFavourite(pokemon.id)),
        onSuccess: () => {
            this.queryClient.invalidateQueries({queryKey: ["pokemon"]]});
            this.queryClient.invalidateQueries({queryKey: ["user"]]});
        }
    }))

    setAsFavourite(pokemon: Pokemon) {
        this.favouriteMutation.mutate(pokemon);
    }
}

injectParams() assumes that we have created a custom hook that uses Angular’s ActivatedRoute to fetch route parameters and returns them as a Signal.

What did we achieve with this solution?

Let’s take the example of just the PokemonDetailComponent.

Instead of managing the result, loading indicators, and errors ourselves, we use injectQuery which solves all these problems automatically. As a result of this function, we get an object that contains functions:

Invalidation after an Action

Did we manage to solve the state invalidation problem after an action with the new solution? Yes!

In the setAsFavourite function, we do not directly call setPokemonAsFavourite(pokemon.id), but instead use favouriteMutation. favouriteMutation is an object we obtained from the injectMutation function. This object wraps the setPokemonAsFavourite function and adds additional functionalities to it. Similar to injectQuery, injectMutation returns an object with functions like isLoading, isError, error, isSuccess, etc., but what is currently important to us is the mutate function and the onSuccess callback.

mutate will simply call the function we define in mutationFn with the parameters we provide. The interesting part comes in the onSuccess callback. Here we can define what will happen after a successful call to mutationFn.

In our case, after the Pokemon is successfully set as a favorite, we want to mark all our queries as invalid and re-execute them. Since Tanstack Query is a complete tool for managing asynchronous events, it knows how to invalidate queries and re-execute them. We simply need to call our queryClient and tell it which queries we want to invalidate. Since each query must have a defined key, we simply tell it that we want to invalidate the query with the key pokemon and the query with the key user.

Tanstack Query has a wide range of options for query invalidation, but this is the simplest way which is sufficient in many cases.

For Those Who Want to Know More

As a resource for learning Tanstack Query, I recommend the official documentation, but also TkDoto’s blog which has already been mentioned. Tanstack Query is very well documented and has a very large user community. Most examples on the internet will be in React, but the API is the same for all adapters.

If the application becomes large enough, maintaining query keys can become a source of headaches. Fortunately, Tanstack Query has very good Devtools that help us track queries and invalidate queries.

One very useful tool I would recommend is @hey-api/openapi-ts. This tool is used for generating TypeScript clients from OpenAPI specifications. It has an adapter for the Tanstack Query Angular client, so it generates functions like getPokemonByIdOptions() and getPokemonByIdQueryKey().

With these useful functions, invalidating a single Pokemon’s data can look like this:

this.queryClient.invalidateQueries({
    queryKey: getPokemonByIdQueryKey({
        path: {
            id: this.pokemon().id,
        },
    }),
});

Conclusion

In this article, we went from a solution that uses side effects to set a class property and causes the ExpressionChangedAfterItHasBeenChecked error, to a solution that heavily relies on RxJs and a “functional programming style”, to a solution that uses modern patterns with Tanstack Query and signals.

In my opinion, Angular is currently in a very strange place. It has a foot in three places at the same time.

Personally, I would not choose Angular for a new project. I believe that until RxJs and Zone.js are completely removed from the core, Angular is unnecessarily complicated and difficult to maintain. To create a web application in Angular, much more Angular-specific knowledge is required compared to using, for example, Svelte or SolidJs which rely more on web standards.

At work, I have to use Angular, so I want to make the job easier for myself and my colleagues, and Tanstack Query is a tool that perfectly balances simplicity and capability.

I simply do not want to maintain my own global event bus and do not want to use Redux patterns through ngrx (even Dan Abramov, the creator of Redux, encourages people not to use Redux unless they have a very good reason for it). I have no problem using RxJs, but it is a very complex library that people often use incorrectly.

Therefore, if you are in doubt about how to manage asynchronous events in Angular, I recommend Tanstack Query as a solution that will save you a lot of time and nerves.