Angular Signals – a new way of change detection

Tomasz Zawiślak

Introduction

Starting from Angular v16 developers have been provided with the really powerful and awaited feature — Signals. It has been promised that Signals will work with more fine-grained reactivity than Observables and will give us finer control over change detection, and that’s exactly how it is. And as a result, productivity increases.

What is a Signal?

In a few simple words:

A Signal is a value plus a change notification.

Let’s examine the difference between a Signal and a simple value (i.e. const or let). Let’s consider the following example:

let x = 10;

We can always access the x (within its scope of course), but we always need to do it “manually” to have its latest value. We’re not notified or aware of changing x value, so probably we can’t react to that properly. Let’s check out the following example:

let x = 10;
let y = 5;
let z = x + y;
console.log('z = ', z); // z = 15

So far so good. Now let’s add one more line to modify the value of y and check what will happen:

let x = 10;
let y = 5;
let z = x + y;
console.log('z = ', z); // z = 15

y = 7;
console.log('z = ', z); // z = 15

As we can see, despite the fact we’ve changed the value of y, the value of z didn’t change – exactly as expected. To make that code do what we intended, after changing the value of y, we need to recalculate the value of z adding the following line: z = x + y; and we need to copy/paste this line every time we change the value of y (or x).

let x = 10;
let y = 5;
let z = x + y;
console.log('z = ', z); // z = 15

y = 7;
z = x + y;
console.log('z = ', z); // z = 17

So the final code should look like the one above. It is not so efficient. This is where Signals come into play. Let’s refactor our simple example using Signals (for now without explaining the Signals syntax):

let x = signal(10);
let y = signal(5);
let z = computed(() => x() + y());
console.log('z = ', z()); // z = 15

y.set(7);
console.log('z = ', z()); // z = 17

The code above will work as we intended. It’s worth noting that the amount of code is not significantly bigger (or smaller) than in the basic example. Additionally, please note how we can access the current value of z – we just simply use the getter: z().

So now, how can we think about all these things to understand the difference between a value and a Signal? Let’s imagine a simple value as something we wrote on a piece of paper to remember and left somewhere. We can always go back there and check this value, but that’s all.

As for a Signal, we can think about it as of a value written on paper and put in a special box. Every time the Signal’s value changes, the box will notify us producing some loud noise, so: 1. we know that the value changed 2. we instantly have the new value because of using getter

And what about using Observables? Let’s compare the same code fragment if we want to use RxJS here:

let x$ = new BehaviorSubject<number>(10);
let y$ = new BehaviorSubject<number>(5);

let z$: Observable<number> = combineLatest([x$, y$])
    .pipe(
        map(([x, y]) => x + y)
    );

Not so much code as well, but if we want to read the value of z$, we need to manually subscribe to this Observable (and then unsubscribe) or use the async pipe in the template. That’s a lot of work and more things to manage.

So what’s the purpose of using Signals, when we can still use Observables? It’s all about change detection. If the consumer (code or template) reads the Signal, that means that it’s interested in watching it, so it’s been notified when the Signal changes (and the view is scheduled to be re-rendered if needed). But change detection for a component will be scheduled when AND ONLY WHEN a Signal read in the template notifies Angular that it has changed. But the consumer is not given the new value instantly. The next time it’s its turn to execute, the consumer reads the current value from the Signal. This improves Angular’s change detection and makes our code more reactive.

How to manage Signals?

There are two types of Signals: writable and computed (read-only).

Writable Signals

When using writable Signals we can directly modify the value. To create a writable Signal, we need to call the signal() function providing its initial value as an argument. Each Signal must always have a value.

const count = signal(0);

Writable Signals have the type WritableSignal. The type of the value can be inferred if we use primitive types. We can also add explicit types when creating the Signal and the value. Let’s extend the line above as follows:

const count: WritableSignal<number> = signal(0);

Signals can contain any value, from simple primitives to complex data structures.

Except for the initial value, we can optionally define how we want to consider two values as equal. The default function uses the standard comparison operator ===, so defining the equal function is especially useful for objects and arrays which are never equal, to allow us to store the values of these types and notify you when they change.

function signal<T>(
  initialValue: T,
  options?: { equal?: (a: T, b: T) => boolean }
): WritableSignal<T>

If we want to change the value, we can use the .set() method to change the value directly:

count.set(15);

or use the .update() method to compute a new value from the previous one:

count.update(value => value * 2);

If our Signal contains an object, we can also mutate the value instead of replacing the entire object. I.e.:

export interface Car {
    name: string;
    price: number;
}

const selectedCar: WritableSignal<Car> = signal({
    name: 'Nice car',
    price: 100000
});

selectedCar.mutate(value => value.price = 80000);

There’s one more method we can use on WritableSignal – .asReadonly(). It returns a read-only version of the Signal (of the Signal type). It can be useful when we want to expose just the value and not give the consumer the possibility to modify the Signal’s value. It can be used the same way as .asObservable() when working with RxJS.

Computed Signals

A computed Signal derives its value from other Signals. To define it, we use the computed() function:

const count = signal(0);
const doubleCount = computed(() => count() * 2);

The type of value can be also inferred here, or we can strongly type it, computed Signal has Signal type:

const doubleCount: Signal<number> = computed(() => this.count() * 2);

Same as for writable Signal, except of the initial value, we can provide the equal function as an option.

The doubleCount Signal depends on count. When we update count, Angular knows that doubleCount needs to be updated as well.

Computed Signals are read-only, we can’t change the value directly, so:

doubleCount.set(2);

will produce compilation errors.

Computed Signals are lazily evaluated and memoized. The first calculation of the computed Signal’s value is run when it’s read for the first time. The calculated value is then cached and every future read of it returns the cached value without recalculating. When the writable Signal value changes, it tells the computed Signal which depends on it that its cached value is no longer valid, but the value will be recalculated only on the next read of the computed Signal.

Change detection

As I mentioned in the Introduction part, Signals “will work with more fine-grained reactivity” and can give us “finer control over the change detection”. But what does it exactly mean? In the current (pre-Signals) approach, during the change detection run, the whole components tree goes through dirty checking because Angular doesn’t know which part of the DOM depends on which part of the view model. We can partially reduce checking the DOM tree by using the change detection OnPush strategy on some parts of the application. Using the Signals approach, Angular knows exactly what has changed and only the DOM nodes which need to be updated will be updated.

How to use Signals in a template?

As mentioned above, to read the Signal’s value we simply use the getter:

const count = signal(0);
console.log('Count value: ', count());

In the same way we can display the Signal’s value in a template:

<div>{{ count() }}</div>

So far so good, but what about calling the function within a template? Angular’s good practice is to always avoid it. That’s true, but here we can do it safely. The view will be only refreshed when the Signal value is changed, not on every change detection, because the Signal getter is a pure function – it has no side effects and returns the same value for the same input (and also uses memoization).

Effects

Using the Signals is very simple and “maintenance-free”, but sometimes we need to manage some code whenever one or more Signal values change. For this purpose we can create an effect using the effect() function:

effect(() => {
    console.log('count value ', this.count());
});

Effects always run at least once. The console.log above will be displayed after the effect is created and then on every value change of count. When using an effect with a computed Signal value, the effect uses the same memoization, so the log will be visible only when any new value is calculated.

The effect can be created only in an injection context, so the most common and practical place to do it is inside the component / directive / service constructor. If we need to create an effect somewhere else, we need to provide the injector context by assigning the effect to the class field:

private countChangeEffect = effect(() => {
    console.log('count value ', this.count());
});

or if we prefer to wrap the effect creation in the class method, we need to provide the injector manually:

constructor(private injector: Injector) {
}

initCountChangeEffect() {
    effect(() => {
        console.log('count value ', this.count());
    }, {injector: this.injector});
}

Since we can do anything we want inside the effect’s callback function, we have to be very careful. Generally speaking, if we need to use an effect, let’s rethink it twice and do everything not to use it.

What can we do inside the effect’s callback function? Many dirty things, like i.e. change the state imperatively, modify the DOM manually, and even run asynchronous code (like http calls)). Although it’s disabled by default, we can even change the Signal’s value after unblocking such a possibility by simply adding {allowSignalWrites: true} into effect() function options.

When to use effects?

According to the official Angular website, there are some

situations where an effect might be a good solution:

  • Logging data being displayed and when it changes, either for analytics or as a debugging tool
  • Keeping data in sync with window.localStorage
  • Adding custom DOM behavior that can’t be expressed with template syntax
  • Performing custom rendering to a <canvas>, charting library, or other third party UI library

Any effect is destroyed automatically when its enclosing context is destroyed, so we don’t need to think about it. Anyway, the effect() function returns EffectRef, so we can manually destroy an effect using the .destroy() method. Additionally, we can add {manualCleanup: true} into the effect() function options, but be careful! When adding this option, we need to take care of actually destroying the effect, because there will be no error thrown if we don’t and the effect will be still alive after destroying the context. The results can be similar to the situation when we don’t unsubscribe from the Observable.

Signals vs RxJS

Observables have been commonly used in Angular applications for many years and we can’t just suddenly get rid of them. Moreover, we shouldn’t do it. Signals can’t replace observables (at least now), but can take over some uses of them. Observables are great tools to manage asynchronous code and data streams, but Signals as a synchronous concept can be better for some cases like i.e. managing the state.

Working together

To create a Signal from observable we use toSignal() function:

const count: Signal<number> = toSignal(count$);

This function subscribes to the Observable passed as an argument and changes the value of the returned Signal every time a new value appears. The subscription will be automatically unsubscribed as soon as the context (i.e. component) in which it had been used is destroyed.

The Signal created from the Observable will store undefined as long as Observable doesn’t emit any value. To avoid undefined (which can stop our application’s work) we can provide initialValue as an option when creating the Signal:

const count: Signal<number> = toSignal(count$, {initialValue: 0});

Using the Observables we can handle different cases using the nexterror and complete notifications. Signals are only interested in emitted values, so using the toSignal() function we can’t handle errors when any occur in the Observable and will show an error the next time when trying to read the value from the Signal. So in this case we need to manually handle errors using the try/catch blocks or the catchError operator.

To create an Observable from the Signal we use the toObservable() function:

const count$: Observable<number> = toObservable(count);

It’s worth noting that all new values of the Signal will be emitted asynchronously to the subscribers. That means that if we synchronously change the Signal value several times, only the last one will be emitted:

const count$: Observable<number> = toObservable(count);
count$.subscribe(value => console.log(value));

count.set(0);
count.set(1);
count.set(2);

// output -> 2

What’s next?

Angular v16 introduced the basis of Signals API. In the nearest future (most likely with v17) we’ll be given more – Signal-based components.

Long story short: Signal-based components don’t use zone.js and are not subject to global Change Detection. They are re-rendered individually according to a fundamental rule:

Change detection will occur only when the Signal whose value we read in the template notifies Angular the value has been changed.

Eventually (and potentially) we can totally get rid of zone.js in the whole app which will highly boost the performance. Why potentially? Because before we do so, all our dependencies must not use zone.js

Conclusion

From a really wide perspective, introducing Signals appears to be a major step forward to the completely new approach of Angular’s change detection – throwing out zone.js seems to be very tempting, but this is the wave of the future. For now, we can start using Signals to make our new code more efficient and our current apps more modern and light-weighted.

This article touched only the basics of the Signals concept, there’s much more to find out and much more to say about this topic. And especially a lot of code to write. I hope my short introduction will be a good starting point for you to do so.

References

Meet the geek-tastic people, and allow us to amaze you with what it's like to work with j‑labs!

Contact us