Angular Signals – How to reuse Backend Results?

After explaining in the last blog post what Angular Signals are and how they are used, I would like to delve into how we at calitime.ch handle Signals to keep our tool TimeRocket soaring through the cosmos in this article.

We don’t use HTTPClient!

First and foremost, I must start with a circumstance that may not be common for many teams using Angular: We do not use HttpClient (anymore).
Normally, HttpClient is the preferred tool in Angular to make HTTP requests to a server. It returns Observables, which allow handling asynchronous data streams. However, I don’t believe that using Observables at this stage was the best choice.
The reason for this is that Observables don’t align with the reality of HTTP requests. The server’s response to an HTTP call is a single response – not a continuous data stream. Yet, an Observable would represent a data stream, even if it only includes a single value change (the initial one).

This is where fetch comes into play. fetch has been the native function in browsers for a few years now to make HTTP resource requests. The advantage of fetch is that it returns Promises, not Observables. A Promise represents a future (one-time) response that will either be successfully fulfilled or fail – precisely what happens with HTTP. This doesn’t mean that Observables should be avoided altogether. In many other cases, such as handling events, they fit perfectly (although Signals are now taking over in that domain, as shown in this blog post).

A word about our app

This leads to us having Promises, which we now want to convert into Signals to display them on the UI.
The curious reader might be wondering (hopefully) why we want to turn Promises into Signals after I explained before that Observables are not a good solution, and Signals are simply the replacement for Observables. If this is the case, excellent, critical reading is essential!

To explain the reason for this, I need to digress and explain some things about our system:
In essence (and for the scope of this post), our app is divided into a Client (Angular) – Server (ASP.net core) architecture. As explained in this article, we automatically generate Angular services from our ASP controllers, which consume the endpoints of the controllers.
Our controllers, and thus the services, are thematically divided. For example, there is an EmployeeController, which is used to query, create, and edit employees. So, there are clearly separated read (Query) and write operations (Commands) following the CQS approach.

This approach is also reflected in the UI: we have so-called “Views,” which make up the majority of the application’s display area and display things (typically, queries are executed here). On the other hand, there are “Actions,” which protrude into the View’s space and are responsible for data manipulation (typically, commands are executed here).
When the data of an employee is edited, and thus a command endpoint is called on the service, we want to recognize which data of the EmployeeService is currently being displayed (e.g., in a view – the employee list) so that we can update this data (i.e., call the endpoint for the employee list again and propagate the new data to the view).

Another requirement is that we do not want to make duplicate backend calls for the same data. So, for instance, if the list of employees is displayed multiple times on the UI simultaneously, we only want one backend call to the Employee list endpoint.

In summary, we need something that provides a caching function that can be invalidated and then knows how to retrieve the data again. And this something must also know if it is still in use, so that the data is not fetched again from the backend if no one is interested anymore (because the data is not currently being displayed).
For this purpose, Signals are very well-suited.

Show me the code!

Let’s imagine a very simple example for a service:

export class TodoService extends SignalService {
  constructor(private fetchService: FetchService) {
  }

  async getTodos() {
    return await this.fetchService.getObject<Todo>('/todos');
  }

  async addTodo(todo: Todo) {
    return await this.fetchService.post('/todos', todo);
  }
}

export interface Todo {
  userId: number;
  id?: number;
  title: string;
  completed: boolean;
}

This TodoService has a query (getTodos) and a command (addTodo). To perform its tasks, it needs the FetchService:

export class FetchService {
  async post<TIn, TOut>(path: string, value: TIn) {
    const body = JSON.stringify(value);
    const response = await fetch(this.toUrl(path), {
      method: 'POST',
      body: body,
      headers: this.getHeaders(),
    });
    const json = await response.json();
    return json as TOut;
  }

  async getObject<T>(path: string) {
    const response = await fetch(this.toUrl(path), {
      method: 'GET',
      headers: this.getHeaders(),
    });
    const json = await response.json();
    return json as T;
  }

  private toUrl = (path: string) =>
    `https://jsonplaceholder.typicode.com${path}`;

  private getHeaders(): HeadersInit {
    const token = '<someToken>';
    return {
      Authorization: `Bearer ${token}`,
      'Content-type': 'application/json; charset=UTF-8',
    };
  }
}

Attention! This is not our actual FetchService. It lacks proper error handling, retrying, real acquisition of the bearer token, additional HTTP methods, etc. It is a highly simplified version of a FetchService, which is sufficient for the purpose of this post.

In the current version, a View that utilizes the TodoService exclusively works with Promises. Now, we want to change that. So, we’ll create a SignalService that can be placed in between the TodoService and FetchService:

export abstract class SignalService {
    private readonly injector: Injector;
    protected constructor(
        protected fetchService: FetchService
    ) {
        this.injector = inject(Injector);
    }

    private createGuid() {
        const s4 = () =>
            Math.floor((1 + Math.random()) * 0x10000)
                .toString(16)
                .substring(1);
        return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
    }

    public onChange = signal(this.createGuid());
    public contentChanged() {
        this.onChange.set(this.createGuid());
    }

    private dictionary: { [url: string]: Signal<any> } = {};
    protected getObject<T>(path: string) {
        if (!this.dictionary[path]) {
            const fetchSignal = signal<T | undefined>(undefined);
            effect(async () => {
                const _ = this.onChange();
                const result = await this.fetchService.getObject<T>(path);
                fetchSignal.set(result);
            }, { injector: this.injector });
            this.dictionary[path] = fetchSignal;
        }

        return <Signal<T>>this.dictionary[path];
    }

    // for conceptual GETs that have to be queried via a HTTP Post (because we need a BODY to send all query data to the backend)
    protected postObject<TIn, TOut>(path: string, value: TIn) {
        const key = `${path}_${JSON.stringify(value)}`;
        if (!this.dictionary[key]) {
            const fetchSignal = signal<TOut | undefined>(undefined);
            effect(async () => {
                const _ = this.onChange();
                const result = await this.fetchService.post<TIn, TOut>(path, value);
                fetchSignal.set(result);
            }, { injector: this.injector });
            this.dictionary[key] = fetchSignal;
        }

        return <Signal<TOut>>this.dictionary[key];
    }
}

This is our actual SignalService that we use in production. For the scope of this blog, we’ll temporarily ignore the postObject(...) method and focus only on getObject(...) and contentChanged(). The main task of getObject(...) is to memoize Signals. When an endpoint is requested for the first time, a Signal is created and returned. From that point on, the previously created Signal is reused.

The Signal has an initial value (undefined), which is immediately overwritten when effect(...) is executed. Inside effect(...), we first listen to the onChange trigger. We ignore the return value; the crucial point is that the function inside effect(...) is called anew every time the onChange Signal receives a new value (and this happens when the contentChanged() method is called).

Every time this occurs (including the first call to getObject(...)), the fetchService is then used to perform the HTTP request. The resulting data is assigned to the fetchSignal as its value, allowing the UI to display the new data.

As the effect function is asynchronous and probably will be called multiple times, the line return <Signal<T>>this.dictionary[path]; (34) is executed earlier than fetchSignal.set(result); (29).

This means, the UI needs to handle the initial value (undefined).

Let’s make a few minor adjustments to our TodoService so it returns Signals:

export class TodoService extends SignalService {
  getTodos(): Signal<Todo[]> {
    return this.getObject<Todo[]>('/todos');
  }

  async addTodo(todo: Todo) {
    const result = await this.fetchService.post('/todos', todo);
    this.contentChanged();
    return result;
  }
}

Now, the TodoService inherits from SignalService, giving it access to getObject(...) and contentChanged(). In getTodos(), we now use the intermediate layer to obtain a Signal. However, in addTodo(...), we still use the fetchService to execute our command. addTodo(...) still uses the async/await keywords and, therefore, results in a Promise. However, after the command is executed, we now call the contentChanged() method to trigger the reloading of todos if a UI element is currently displaying the todo list.

In this StackBlitz, you can see the entire interplay of the individual services. Unfortunately, the dummy API I used (https://jsonplaceholder.typicode.com) does not provide the ability to actually add a todo. So, the list of todos remains unchanged with the same 200 elements. However, I added a console.log in the computed(...) method of the component to demonstrate that after an addTodo(...), the todos are indeed reloaded (you can also verify this in the Network tab of your browser’s DevTools).

Now, let’s explain why I had to use { injector: this.injector } in the effect of the SignalService: Unfortunately, I didn’t find much more information about this other than what you can read on https://angular.io/api/core/CreateEffectOptions. An effect lives in an Injection Context, which is usually provided by creating the effect within a constructor. However, in this instance, this is not the case, so we have to explicitly specify which Injector the effect can use. For this purpose, I use the Injector obtained in the constructor of the SignalService. I use the inject(...) function instead of receiving the Injector via constructor arguments because the SignalService itself is not an @Injectable(...), but an abstract class, and I don’t want to pass the Injector and FetchService via super(...) in every concrete service class (e.g., TodoService). If anyone knows exactly why an effect(...) needs to know in which Injection Context it exists, I would appreciate a comment.

I had actually intended to show much more about how we use Signals in our app, but I think this post is already long enough. So, there will be another post from me on this topic…stay tuned!

About the author

Domenic Helfenstein

1 comment

Recent Posts