Photo by Toa Heftiba on Unsplash
I know you have been waiting for the follow-up of my guide to migrate an Angular app to a micro-frontend, but you would have to wait a little longer. As you may know, or maybe not, Angular is updating its change detection model with a reactivity approach, using a new feature called Signal.
What is a Signal?
A signal is a new way to declare and manage states. Until now, to create a state in a component, we usually just create a property and update the property when we need to. This was a good feature for Angular back in the day, but to implement that feature Angular had to use some other features such as the infamous Zone.js. Zone.js was a great idea, just monkey-patch every possible event emitter, and then listen to them and update the UI by checking what was changed, or what should be changed. I won't talk deeply about how the current model of Angular works, but that's mostly it.
Now, you can think of Signals, like a React hook. They are both internal functions that allow us to keep track of an internal state and react to updates, and with features to update the state when we want. By using Signal instead of plain properties, Angular can now track and update only the values of the Signals that have been updated, without the need to listen to every event in the DOM. This will allow zone-less applications, among other features.
How can you use a Signal?
Signals are officially in developer preview and are available in the latest version of Angular, Angular 16. With the next version of Angular, additional features will be enabled for developers, such as Signal-only components, that will use inputs based on signals to work, and zone-less applications. We can talk about those features once they become widely available, but right now, we can play with the signal function.
To use and update a signal, we need to import the signal function from @angular/core
, call the function with an initial value, and assign it to a property in a component. With that, if we want to update the value, we can use the methods of the signal to do so.
import { Component, signal } from "@angular/core";
@Component({
selector: "app-root",
template: `
<h1>{{ title() }}</h1>
<button (click)="title.set('Updated title')">Update title</button>
`,
})
export class AppComponent {
title = signal("Some title");
}
That's it. It's really simple, more explicit, and internally it will allow Angular to perform better.
But we usually don't have a setup this simple, right? That's why we are going to build a function that helps us to query.
The query helper
What do we need when we query for something? Most of the time, we care about the data, some kind of status like if the query is loading, or if it had an error.
What would a helper need to work? Well, the query function. That should be it, at least that I think is the minimum, and that's what we are going to do here.
So, let's start by creating a function that stores the query value once it has been completed:
import { signal } from "@angular/core";
export const querySignal = (queryFn: Function) => {
const internalData = signal(void 0);
const fetch = async () => {
try {
let response = await queryFn();
internalData.set(response);
} catch (e: any) {}
};
fetch();
return internalData.asReadonly();
};
We are returning
internalData.asReadonly()
because this will return a new read-only signal that will prevent undesired external updates to the query data.
And then, we can use the helper as:
import { effect, Component } from "@angular/core";
@Component({
selector: "app-root",
template: `
<pre>
{{ users() | json }}
</pre
>
`,
})
export class AppComponent {
users = querySignal(() =>
fetch(`https://fakerapi.it/api/v1/persons`)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error, status = ${response.status}`);
}
return response.json();
})
.then((res) => res.data as any[])
);
}
Of course, you could use a service to handle the query, but the important part here is that we are using plain promises, instead of Observables.
Moving forward, we will use a
PersonService
, with agetAll
method and assume the code returns a promise.
You may notice that we are not using typings for this, and because of that "users" is typed as Signal<any>
. We can fix that by using generics. Now, since we have our helper already working, maybe we could add some features, such as a state to know if the query had any errors, or if it is still loading, etc. Let's update our query helper.
First, let's add the generic typing so we can leverage of TS type system. Let's add the Data
generic type, and also, let's create a QueryFn
type to handle the type of the query function instead of using the Function
type ourselves.
import { signal, Signal } from "@angular/core";
export const querySignal = <
Data,
QueryFn extends (...args: unknown[]) => Data | Promise<Data>
>(
queryFn: QueryFn
): Signal<Data | undefined> => {
const internalData = signal<Data | undefined>(void 0);
const fetch = async () => {
try {
let response = await queryFn();
internalData.set(response);
} catch (e: any) {}
};
fetch();
return internalData.asReadonly();
};
With this, we can update our component as:
import { effect, Component } from "@angular/core";
@Component({
selector: "app-root",
template: `
<pre>
{{ users() | json }}
</pre
>
`,
})
export class AppComponent {
users = querySignal(this.persons.getAll); // Correctly typed as QuerySignal<Person[] | undefined>
constructor(private readonly persons: PersonService) {}
}
We can learn a few things about Signals now that we are here. Notice that we are calling directly the internal fetch
function in the QuerySignal. This is fine, since our persons.getAll
method doesn't have any argument dependencies, but don't you agree it would be awesome if we could trigger this automatically each time we update one of the arguments? Well, we can use effect
to achieve just that. First, we need to update our code service with an argument, then we update the code of the query helper, then we update our component to implement those changes.
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class PersonsService {
constructor() {}
public async getAll(quantity: number): Promise<Person[]> {
const response = await fetch(
`https://fakerapi.it/api/v1/persons?_quantity=${quantity}`
);
if (!response.ok) {
throw new Error(`HTTP error, status = ${response.status}`);
}
const res = await response.json();
return res.data;
}
}
import { effect, signal, Signal } from "@angular/core";
export const querySignal = <
Data,
QueryFn extends (...args: any[]) => Data | Promise<Data>
>(
queryFn: QueryFn,
queryParams: Parameters<QueryFn>
): Signal<Data | undefined> => {
const internalData = signal<Data | undefined>(void 0);
const fetch = async (params: Parameters<QueryFn>) => {
try {
let response = await queryFn(...params);
internalData.set(response);
} catch (e: any) {}
};
effect(() => {
fetch(queryParams);
});
return internalData.asReadonly();
};
import { Component, signal } from "@angular/core";
import { querySignal } from "./query-signal";
import { PersonsService } from "./persons.service";
@Component({
selector: "app-root",
template: `
<button (click)="quantity.set(10)">10 users</button>
<button (click)="quantity.set(20)">20 users</button>
<pre>{{ users() | json }}</pre>
`,
})
export class AppComponent {
title = signal("query-signal-app");
quantity = signal(10);
users = querySignal(this.persons.getAll, [this.quantity()]);
constructor(private readonly persons: PersonsService) {}
}
If we use this code as it is, you will soon find out that it doesn't update the query when we click on the buttons. That's because we are passing the quantity directly as a value, and to be able for Angular to pick up the changes, we need to call a signal inside the effect callback function and to call a signal, we need to pass a signal. We will keep it simple from now and use some type helpers as we did to extract the argument types to do the type checking. Additionally, we will be using the compute
function to create a new signal with the value of the arguments that we need.
import { effect, signal, Signal } from "@angular/core";
export const querySignal = <
Data,
QueryFn extends (...args: any[]) => Data | Promise<Data>
>(
queryFn: QueryFn,
queryParams: Signal<Readonly<Parameters<QueryFn>>>
): Signal<Data | undefined> => {
const internalData = signal<Data | undefined>(void 0);
const fetch = async (params: Readonly<Parameters<QueryFn>>) => {
try {
let response = await queryFn(...params);
internalData.set(response);
} catch (e: any) {}
};
effect(() => {
fetch(queryParams());
});
return internalData.asReadonly();
};
import { Component, computed, signal } from "@angular/core";
import { querySignal } from "query-signal";
import { PersonsService } from "./persons.service";
@Component({
selector: "app-root",
template: `
<button (click)="quantity.set(10)">10 users</button>
<button (click)="quantity.set(20)">20 users</button>
<pre>{{ users() | json }}</pre>
`,
})
export class AppComponent {
title = signal("query-signal-app");
quantity = signal(10);
getAllParams = computed(() => [this.quantity()] as const);
users = querySignal(this.persons.getAll, this.getAllParams);
constructor(private readonly persons: PersonsService) {}
}
effect
andcompute
are two functions that will help us to react to signal changes.
- The
effect
function is called with a callback function, that will run each time one or more signals used inside are updated.- The
compute
is also called with a callback function but will help you to get a derivate value from other signals, updating itself each time any of the signals has been updated. It returns a signal storing the returning value of the function callback.
After this, you will notice that once you go between the two buttons, you will get the list refreshed.
Wrapping up!
You can see some of this code in action below, although I have to say I'm using Bootstrap here to demo, and additionally, it's using some features that I didn't explain here, which you can use in the library @barelyshaped/query-signal
.
So long Observables, I won't miss you
Until now, all we had to manage states were observables, and because of this, all we did was use and misuse them for everything. And if you had enough of them, you know how painful it could be to detect and fix an issue, and don't you dare to say to me "You should use marble testing instead", just don't. Observables are difficult to reason, test, and understand. Promises are plain, just "do something, and then...". I won't argue though, that Observables have their benefits, of course, but in my experience, most people using Observables don't understand them enough to use those benefits, and because of that we end up with overly complicated code that it's not using any of the benefits.
Maybe some Angular developers with more experience will think they will miss something by losing Observables, but we won't. Observables were a nice feature, same as Zone, decorators, and even modules. But web development has moved away from those features, and Angular has been updated with that movement.