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!
[…] my last article, I explained how our communication with the backend takes place and how we were able to build a […]
… [Trackback]
[…] Read More: planetgeek.ch/2023/07/27/angular-signals-how-to-reuse-backend-results/ […]