Goodbye Getter, Hello Signals
Use Angular 16 Signals to Avoid a Common Code Review Catch
Angular 16 introduced Signals, an all-new reactive primitive designed to improve how we, as Angular developers, deal with reactive programming. Signal is a powerful new data type, with lots of exciting possibilities. At ng-conf 2023, I learned about Signals firsthand from the Angular core team and some of the best minds in the Angular community. As they spoke about Signals, I saw an opportunity to change the way I code, specifically with a TypeScript feature that I always find myself needing, but always gets pointed out as a no-no in code review
Let’s see how Signals can fix the dreaded “binding to a function in the template” critique once and for all.
Don’t Bind to a Function
You may have seen this sentiment on the internet, or in one of your code reviews. “Do not bind to a function in your component template!” That person is not just being a stickler, there’s a very good reason not to bind to function calls in your template: app performance suffers. Here is an example of a function being used in a data binding:
This type of binding will work. This will evaluate the function as you wrote it and Angular will receive the value properly. However, using a function has some downsides with how Angular’s change detection works. Angular uses change detection to determine what bound template values have changed and then updates the DOM accordingly only for the values that have changed. This optimizes performance by making as few changes to the DOM as possible.
The problem is, Angular has no way of knowing if values used inside a function have changed. It knows that you are calling this function, but it does not know the contents of the function. Because of this, Angular will run this function on every change detection cycle to make sure the function result is updated. Even if a totally unrelated value changes, this function is getting run. This might be acceptable for simple functions, but if your function does things like array iteration or object creation, these multiple calls can get expensive very quickly.
The same thing applies when binding to a TypeScript getter. Let’s look at this example, displaying a list of users whose age is an even number:
When invoking the getter, it doesn’t look like a function. There are no parentheses being used to invoke it, but it is a function at the end of the day, just with some syntactic sugar to hide it.
I wrote an example app to see how many times my getter that filters an array is called. On component load, before there were any values in the array, it was called six times. This is before the array being filtered has even changed!
Not only that, every time I add a new value to my array, the getter is called twice, one of those times being completely unnecessary. This is the call count for the simplest possible page with barely any values changing. Imagine how many times this would get called in a more complex view. Binding to a function can cause serious performance problems if the operations you perform are expensive. Never fret! Signals are here to save the day.
Switch it to Signals!
Along with the new Signal reactive primitive come computed signals. A computed signal derives its value from other signals. Let’s rewrite our example code with Signal instead.
Here, we declared a WritableSignal that represents the list of users. We want it to be Writable because we want the ability to call .set(), .update(), or .mutate() on it to update the list of users as we see fit. We then create a new Signal from the computed operator.
Computed signals look to see what Signals they make use of and “watch” them, only updating their value when those signals change. If you use multiple signals in one computed signal, it will track updates to each of those signals and recompute.
Computed signals are also:
- lazy, meaning they only update their value the first time one of their signal values is read. No unnecessary operations here!
- memoized, meaning it will only reevaluate itself when the value of its signal changes. If your code were to read this computed signal in another place, but the usersSignal value has not changed since the last read, the computed function would not be rerun. The last known value is memoized, which increases performance.
Let’s see how many times my computed signal is run when my component loads…
My computed function was only called once! Additionally, every time my usersSignal value is updated, my computed function is called once. For expensive operations like array iteration and object comparisons, this is a huge performance booster.
One Thing Signals Can’t Do (Yet)
There is one way that the computed Signal approach cannot replace functions used in the template, and that is when you need to provide some arguments to the function for the calculation.
Unfortunately, the Signal API does not give us a way to rewrite this isUserVIP function that accepts an argument with an exact computed Signal equivalent. Computed signals can’t receive parameters. The Signals API is still in developer preview and is actively being iterated on. Maybe we’ll get a way to replicate this code pattern with Signal in the future! If you really wanted to use signals, this particular example could be refactored to have the user VIP status as a computed signal, defined ahead of time in the component. You can then reference the predefined computed signal in the template, rather than calling a function to get the status.
Conclusion
Signals are a powerful new addition to Angular that will change the way we write our Angular code. Already we are seeing that Signals are smart, and can replace older code patterns that cause performance issues. Signals are opening up new ways to bind complex logic to our templates, without sacrificing performance.
Signals are available as a developer preview today in Angular 16! Try it out, and see what you can do with this powerful new data type.