Angular Signals: what's all the fuss about?
In recent releases of Angular, signals have been introduced, which are going to fundamentally change how we use this framework. Let's take a closer look at what they are and how they came to be.
Introduction
In the last couple of months the word signals has been said a lot in the Angular communities. Everybody already seems to either love them madly or reject them passionately.
But for those of us who are late to the hype train: what are they? And what can they do or not do?
Worry no more with FOMO as by the end of this article you will have a more comprehensive idea of where signals come from, how they work, and more importantly how they fit into the RxJS observables world.
Genesis
Let’s start with the basics: why were they created? One may think that it was just a matter of “let’s add a new feature to the framework”, but it’s not that simple. And it’s not even a matter of copying what other frameworks are doing, as signals are not a new concept, they have been around for a while, and they are used in other libraries like Preact or frameworks like SolidJS. Indeed, SolidJS author himself says signals have been around “since the dawn of declarative JavaScript Frameworks”.
This is not a case of “monkey see, monkey do”, but rather a case of “let’s see what we can do better”.
The reasoning behind these changes can be well represented by this quote by the tech lead of the Angular team, Alex Rickabaugh:
The nature of web applications is always evolving, the performance requirements evolve, the web platform itself is evolving. So Angular needs to be stable, but not stagnant”. And so signals were born.
As a matter of fact, for the last decade, Angular’s team listened to the users’ most requested features to improve the developer experience. Amongst other things, some reoccurring requests concerned the necessity to simplify the usage of Angular, requiring less boilerplate code, augmented performances, more reactivity, and finer control over the components.
(Following this link you will find the PR that answered these requests, introducing signals for the first time.)
Change detection
To gain a better understanding of these requests, we must first digress a little bit, explaining a fundamental concept of Angular: change detection.
At the time of writing the framework uses a library called zone.js to control the various changes of state inside the application.
Zone.js tracks all the browser’s events and every time it sees that something has changed, it starts a change detection cycle, in which it checks the components tree of the app to see if something has been modified, updating the views that need to be updated. On the one hand, this behavior is extremely useful because it automatically manages these checks, on the other hand, it underperforms because it constantly checks all of the components, sometimes even when there is no need to update anything.
An alternative strategy can be to use zone.js with OnPush in every component. This makes it so that the component that uses it is not checked during a change detection cycle unless explicitly marked with the markForCheck()
function.
This behavior may seem similar to the one before, but the main difference is that markForCheck()
doesn’t automatically trigger the change detection for its component, but it just marks it to be checked on the next change detection cycle scheduled by zone.js .
The downside to this strategy is that it marks not just the component that raised the markForCheck()
flag, but also its parent nodes. So we have fewer components checked than before, but there is still some, let’s say over-checking spillage.
With signals, however, change detection is much more precise, indicating only the component, or components, that have undergone some changes.
But what is a signal?
A signal can be seen as a box around a value, and it can alert interested consumers when its value changes.
It can contain every kind of value, from primitive ones to more complex data structures. The value of a signal is always read using a getter function, which is what allows Angular to track when it has been used.
Side note: as a signal contains a value, it must always be initialized, or by default, it will be equal to undefined
, which is not ideal.
A signal can be of one of two categories:
- Writable, which contains a value that can be directly edited
- Computed, in which the value is derived from other signals; in this case, we can’t directly modify the computed value but this type has the added plus that it has a lazy approach. This means that its value will be re-evaluated not when the signals it checks are updated, but when a consumer tries to read the derived computed value, which makes it useful to do complex operations like array filtering.
Signals VS observables
By now, the more careful reader would probably start to think “By this explanation, signals seem to be a copy of RxJS observables, as they seem to do the same thing, so are we reinventing the wheel here ?”.
To answer this question we need to open another small parenthesis before continuing, to get a clearer context for the ones among us who don’t know what observables are.
What is an observable?
An observable is a collection of objects which can be observed in time, they are part of alibrary called RxJS which is often used in tandem with the Angular framework to manage asynchronous events. Unlike a normal array it does not keep memory of the elements, it just emits them.
To get a silly visual aid: we can imagine an observable as a fast food employee manning the drive-through window: it just knows that it has to deliver bags of objects to consumers as time passes, in an uninterrupted line of customers that go by during the day.
As much as they can work with synchronous data, observables shine in working with asynchronous events (clicking on keyboard keys, mouse clicks, HTTP calls responses) or notifications (a completed or failed process). But they are not perfect, as observables require a manual subscription, and of course a manual un-subscription. Moreover, the data is not readily available but has to be extracted from the emitted values stream first. On the other hand, signals don’t need a manual subscription, or more precisely, they have an implicit quasi-subscription automatically managed when a consumer starts listening to a signal’s value. In addition, a signal can be called and read directly, immediately obtaining the value it encapsulates. These may seem like minor differences, but for simpler actions signals require much less code and, more importantly, less RxJS experience, which can be extremely powerful but also really difficult for new (and even not-so-new) devs.
Are we going to use only signals?
So, the one-million-dollar question is: are signals better than observables? Well, it depends.
If you need to observe (no pun intended) values that change with time, without knowing when they do change, so asynchronously, you will be better off using observables. Instead, if time isn’t something you need to keep in the equation, you will need only signals, which are simpler to use. Realistically, in the future, we will most likely use signals for most use cases, and only in some particular circumstances we will need all the power of observables.
Having reflected upon these differences, you can see how titles that claim that the end of RxJS in favor of signals is imminent are just click-bait. It’s just a matter of knowing when to use the right tool for the right job.
More so, the RxJS team, seeing the big picture, has already implemented two functions that allow the interoperability between signals and observables:
toObservable()
and toSignal()
, allowing the management of complex data flux or asynchronous data without having to give up the usage of signals.
I want to highlight this point, as I think it’s one of the key concepts of Angular and its libraries: to always allow retro compatibility, letting new and old functionalities live side-by-side without the risk of breaking anything, which is not to be taken for granted. So let’s keep feuds to important matters, like if you can add heavy cream to a carbonara sauce (pro tip: never).
Practical examples
Here are some practical examples before and after the usage of signals. They were made by Deborah Kurata, an amazing tech content creator. At this GitHub link you will find the GitHub repository in which you can find her complete project. These examples are based upon a shopping app, in which we can add items to a cart, and then we can see the total price of the items in the cart. On the left we have the observables version, on the right the signals one. (Note: here I will call the signals version “after” and the observables one “before” for simplicity’s sake. Also, the code is simplified for the sake of brevity, but the full code is available in the GitHub repo. Last but not least, instead of Observable observables, here are used Subject observables; an RxJS Subject is a special type of Observable that allows values to be multicasted to many Observers, so for simplicity’s sake I will refer to them just as observables).
Let’s start with something simple: the differences in the HTML and TS files of the cart-total component. The differences are not huge, but they are there. Especially in the HTML file, in the before version, we have to use the async pipe to subscribe to the observable, while in the after version we can just call the signal directly; this can make a difference in streamlining the code if we have to use other pipes, as in the case of | number
.
cart-total.component.html before
<div
class="card border-secondary"
*ngIf="(cartItems$ | async)?.length; else noItems"
>
<div class="card-header text-secondary fw-bold">
<div class="row">
<div class="col-md-12">Cart Total</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">Subtotal:</div>
<div class="col-md-4" style="text-align:right">
{{subTotal$ | async | number:'1.2-2'}}
</div>
</div>
<div class="row">
<div class="col-md-4">Delivery:</div>
<div
class="col-md-4"
style="text-align:right"
*ngIf="deliveryFee$ | async as deliveryFee"
>
{{deliveryFee | number:'1.2-2'}}
</div>
<div
class="col-md-4"
style="text-align:right;color:red"
*ngIf="!(deliveryFee$ | async)"
>
Free
</div>
</div>
<div class="row">
<div class="col-md-4">Estimated Tax:</div>
<div class="col-md-4" style="text-align:right">
{{tax$ | async | number:'1.2-2'}}
</div>
</div>
<div class="row">
<div class="col-md-4"><b>Total:</b></div>
<div class="col-md-4" style="text-align:right">
<b>{{totalPrice$ | async | number:'1.2-2'}}</b>
</div>
</div>
</div>
</div>
cart-total.component.html after
<div class="card border-secondary" *ngIf="cartItems().length; else noItems">
<div class="card-header text-secondary fw-bold">
<div class="row">
<div class="col-md-12">Cart Total</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">Subtotal:</div>
<div class="col-md-4" style="text-align:right">
{{subTotal() | number:'1.2-2'}}
</div>
</div>
<div class="row">
<div class="col-md-4">Delivery:</div>
<div class="col-md-4" style="text-align:right" *ngIf="deliveryFee()">
{{deliveryFee() | number:'1.2-2'}}
</div>
<div
class="col-md-4"
style="text-align:right;color:red"
*ngIf="!deliveryFee()"
>
Free
</div>
</div>
<div class="row">
<div class="col-md-4">Estimated Tax:</div>
<div class="col-md-4" style="text-align:right">
{{tax() | number:'1.2-2'}}
</div>
</div>
<div class="row">
<div class="col-md-4"><b>Total:</b></div>
<div class="col-md-4" style="text-align:right">
<b>{{totalPrice() | number:'1.2-2'}}</b>
</div>
</div>
</div>
</div>
cart-total.component.ts before
export class CartTotalComponent {
cartItems$ = this.cartService.cartItems$;
subTotal$ = this.cartService.subTotal$;
deliveryFee$ = this.cartService.deliveryFee$;
tax$ = this.cartService.tax$;
totalPrice$ = this.cartService.totalPrice$;
constructor(private cartService: CartService) {}
}
cart-total.component.ts after
export class CartTotalComponent {
cartService = inject(CartService);
cartItems = this.cartService.cartItems;
subTotal = this.cartService.subTotal;
deliveryFee = this.cartService.deliveryFee;
tax = this.cartService.tax;
totalPrice = this.cartService.totalPrice;
}
The most important differences, however, are in the cart service. In the before version, we have to manually subscribe to the observable, and then manually un-subscribe from it, which is not ideal. Especially considering that we will have to do it for all of the observables we use. In the after version, we can just call the signal directly, and it will be automatically managed by Angular.
Another important difference is that in the before version we have to manually manage the initialization of the observables, while in the after version we can just initialize the signals with the value we want them to have.
Also, in the before version, if we need to derive a value from other values, we have to use RxJS functions concatenated by .pipe(), which can make the code difficult to read for someone who doesn’t know this library very well, while in the after version we can just use the computed()
signal type, resulting in a much easier to understand code. We can also see how in the before version we have to use the next()
function to update the value of the observable, specifying an action, while in the after version we can just update the value of the signal directly. Lastly, in the after version we don’t need the modifyCart()
function, as we can just update the value of the signal directly in the function, making following the flow of data more fluid.
cart.service.ts before
export class CartService {
// Add item action
private itemSubject = new Subject<Action<CartItem>>();
itemAction$ = this.itemSubject.asObservable();
cartItems$ = this.itemAction$.pipe(
scan(
(items, itemAction) => this.modifyCart(items, itemAction),
[] as CartItem[]
),
shareReplay(1)
);
// Total up the extended price for each item
subTotal$ = this.cartItems$.pipe(
map((items) =>
items.reduce(
(a, b) => a + b.quantity * Number(b.vehicle.cost_in_credits),
0
)
)
);
// Delivery is free if spending more than 100,000 credits
deliveryFee$ = this.subTotal$.pipe(map((t) => (t < 100000 ? 999 : 0)));
// Tax could be based on shipping address zip code
tax$ = this.subTotal$.pipe(map((t) => Math.round(t * 10.75) / 100));
// Total price
totalPrice$ = combineLatest([
this.subTotal$,
this.deliveryFee$,
this.tax$,
]).pipe(map(([st, d, t]) => st + d + t));
// Add the vehicle to the cart as an Action<CartItem>
addToCart(vehicle: Vehicle): void {
this.itemSubject.next({
item: { vehicle, quantity: 1 },
action: "add",
});
}
// Remove the item from the cart
removeFromCart(cartItem: CartItem): void {
this.itemSubject.next({
item: { vehicle: cartItem.vehicle, quantity: 0 },
action: "delete",
});
}
updateInCart(cartItem: CartItem, quantity: number) {
this.itemSubject.next({
item: { vehicle: cartItem.vehicle, quantity },
action: "update",
});
}
// Return the updated array of cart items
private modifyCart(
items: CartItem[],
operation: Action<CartItem>
): CartItem[] {
if (operation.action === "add") {
// Determine if the item is already in the cart
const itemInCart = items.find(
(item) => item.vehicle.name === operation.item.vehicle.name
);
if (itemInCart) {
// If so, update the quantity
itemInCart.quantity += 1;
return items.map((item) =>
item.vehicle.name === itemInCart.vehicle.name ? itemInCart : item
);
} else {
return [...items, operation.item];
}
} else if (operation.action === "update") {
return items.map((item) =>
item.vehicle.name === operation.item.vehicle.name
? operation.item
: item
);
} else if (operation.action === "delete") {
return items.filter(
(item) => item.vehicle.name !== operation.item.vehicle.name
);
}
return [...items];
}
}
cart.service.ts after
export class CartService {
// Manage state with signals
cartItems = signal<CartItem[]>([]);
// Total up the extended price for each item
subTotal = computed(() =>
this.cartItems().reduce(
(a, b) => a + b.quantity * Number(b.vehicle.cost_in_credits),
0
)
);
// Delivery is free if spending more than 100,000 credits
deliveryFee = computed(() => (this.subTotal() < 100000 ? 999 : 0));
// Tax could be based on shipping address zip code
tax = computed(() => Math.round(this.subTotal() * 10.75) / 100);
// Total price
totalPrice = computed(
() => this.subTotal() + this.deliveryFee() + this.tax()
);
// Add the vehicle to the cart
// If the item is already in the cart, increase the quantity
addToCart(vehicle: Vehicle): void {
const index = this.cartItems().findIndex(
(item) => item.vehicle.name === vehicle.name
);
if (index === -1) {
// Not already in the cart, so add with default quantity of 1
this.cartItems.mutate((items) => items.push({ vehicle, quantity: 1 }));
} else {
// Already in the cart, so increase the quantity by 1
this.cartItems.mutate(
(items) =>
(items[index] = { vehicle, quantity: items[index].quantity + 1 })
);
}
}
// Remove the item from the cart
removeFromCart(cartItem: CartItem): void {
// Update the cart with a new array containing
// all but the filtered out deleted item
this.cartItems.update((items) =>
items.filter((item) => item.vehicle.name !== cartItem.vehicle.name)
);
}
updateInCart(cartItem: CartItem, quantity: number) {
// Update the cart with a new array containing
// the updated item and all other original items
this.cartItems.update((items) =>
items.map((item) =>
item.vehicle.name === cartItem.vehicle.name
? { vehicle: cartItem.vehicle, quantity }
: item
)
);
}
}
Conclusion
Now that we can see the big picture we can reflect on the initial users’ requests made to the Angular team and it will be clear that they have been heard and answered.
Thanks to signals we will have more simplicity in usage to track values for changes in the apps, especially for those who are beginning to work with the framework, greatly reducing boilerplate code.
They will give the apps more reactivity, giving a boost to performances more than even the zone.js + onPush strategy could, without losing any functionality. They will allow much finer control over single components and the value changes inside them, automatically managing the subscription/un-subscription part.
But, as we have seen, signals will not replace observables completely, they will instead co-exist with them in harmony.
Let’s make love code, not war.