Angular Signals — Timing

🪄 OZ 🎩
6 min readMay 6, 2023

--

“Charing Cross Bridge”, Claude Monet, 1903

Angular v16 has been released, and my article is about the most important feature that this release brings: Angular Signals. I’ll show you, how and when computed() and effect() run the functions you send to them.

Update (Jun 24, 2024): Code example dependencies were updated to Angular v18, and tests still pass.

Angular Signals

While there are no official tutorials about Signals yet, the API is described in the RFC and the source code is available.

Some developers think that Angular Signals are quite similar to signal implementations in other frameworks and libraries, but there are some important differences to note.

Describing computed(), RFC says:

Computations are lazy: the computation function is not invoked, unless someone is interested in (reads) its value.

Also:

Computed values are not updated if there was no update to the dependent signals

These are quite important differences.

About the effects, RFC says:

effects will execute at least once;

effects will execute in response to their dependencies changes at some point in the future;

effects will execute minimal number of times: if an effect depends on multiple signals and several of them change at once, only one effect execution will be scheduled.

Lazy Computations

There are two important terms in Angular Signals reactivity:

  • Signals are called “producers” — they produce new values (in other words, they produce reactivity);
  • Angular templates and functions passed to computed() or effect(), are called “consumers”. They consume reactivity (values, produced by signals).

Because computed() creates a signal, it is both a producer and a consumer.

When a signal is read in a template or in a function passed to computed() or effect(), a “producer <-> consumer” connection, similar to a "subscription" in observables, is created.

One of the important differences between signals and observables is that signals do not send their new value to their consumers when their value is modified. Instead, they simply notify their consumers that the value has been modified.

To fetch the new value of a signal, the consumer should “read” that signal. Derived signals (created with computed()) produce new values by executing their computations. And only the consumer decides when to do this.

Because of this, computations in Angular Signals are called “lazy” — they will not re-run themselves until the consumer asks them to.

The consumer might decide to read the value synchronously, asynchronously, or never.

Memoization

Consumers use memoization. If every signal they depend on was not modified, computed() will not re-run its computation, and effect() will not re-execute its function.

When a new value is passed to a signal, using methods set and update, signal checks, if the new value equals the existing value. If values are equal, consumers will not be notified (no code will be executed at all).

To see how it all works in practice, let’s write a small set of tests:

computed()

Let’s create a derived signal from two signals:

const x = signal(1);
const y = signal(-1);

// Counter of how many times our computed signal was run:
let runs = 0;

const c = computed(() => {
runs++;
return Math.pow(x(), y());
});

The signal has been created, and no runs are expected at this time.

runs: 0

Let’s read our derived signal:

c();

Now, we do expect one run since the computed signal was just read (“unwrapped”):

runs: 1

Let’s update our signals and check if it will cause a recomputation of the computed signal:

x.set(2);
y.set(2);

Because computed Angular Signals are lazy, there will be no runs until the computed signal is read.

runs: 1

Even in the next microtask:

setTimeout(() => {
console.log('runs:', runs);
});
runs: 1

But when we read it,

const v1 = c();

it will be recomputed once. And we will get the computation for the latest values of the involved signals.

runs: 2
v1: 4

Multiple modifications of the signals will don’t affect the number of runs. The computed signal will run as many times as it was read:

x.set(3);
x.set(4);
x.set(5);
x.set(6);

const v2 = c();
runs: 3
v2: 36

If the involved signals were not modified between the reads, computation will not be recomputed:

const v3 = c();
runs: 3
v2: 36

This is because computed() uses memoization.

effect()

Again, two signals, and a value that our side-effect will modify:

const x = signal(1);
const y = signal(-1);

let runs = 0;
let v = 0; // will be modified by our side-effect

effect(() => {
runs++;
v = Math.pow(x(), y());
});

As soon as it is created, effect() will run, but in the next microtask:

setTimeout(() => {
// We haven't modified our signals, so this run is just
// a scheduled run from the creation moment.
console.log('runs:', runs);
console.log('v:', v);
});
runs: 1
v: 1

Now let’s modify our signals.
Both of them: 2 modifications in total.

x.set(2);
y.set(3);

Despite these modifications, runs still equals 1, and the value is not updated.

runs: 1
v: 1

This is because the next run is scheduled for the next microtask. And then we can see that our effect() was run once:

setTimeout(() => {
console.log('runs:', runs);
console.log('v:', v);
});
runs: 2
v: 81

Will effect() run if a signal is updated but not changed?

x.set(9);

No, effect() is a consumer, and memoization works here as well:

runs: 2
v: 81

computed(), watched in the template

When a derived signal is “unwrapped” in the template, the template becomes a consumer of this signal — this consumer behaves similarly to effect(). When the template is notified about the possible modification of a signal, it will re-read that signal in the next microtask.

Let’s test it:

watchMe = signal(0);
wRuns = 0;

// derived signal
watched = computed(() => {
wRuns++;
return Math.abs(watchMe() * 2);
});
<div>{{ watched() }}</div>

This signal is being watched by the templated, so it will be executed instantly.
In this test we will not read the value of the signal — only the template will.

wRuns: 1

Computations are lazy, so they will not be executed until the signal is read. But if a signal is being watched by the template, it will be read every time when at least one of the signals that this computed signal depends on is changed. So this computation is not so lazy anymore, along with every computation it depends on.

watchMe.set(1);

Template’s watcher is similar to effect(), so it will re-read values in the next microtask, and then our watched computation will be recomputed:

setTimeout(() => {
console.log('wRuns:', wRuns);
});
wRuns: 2

Even if a computation itself will return the same value, it will still notify the consumer (in this case — the template), and it will still be recomputed (simply because we can not know the new value in advance).

this.watchMe.set(-1);

setTimeout(() => {
console.log('wRuns:', wRuns);
});
wRuns: 3

But if all the dependencies of this computed signal will return the same values, it will not notify the template, and, therefore, will not be recomputed:

this.watchMe.set(-1);

setTimeout(() => {
console.log('wRuns:', wRuns);
});
wRuns: 3

If the template’s watcher re-reads the signal and the new value is equal to the previous one, memoization will save us from unnecessary updates of the DOM.

I hope this article and the tests will help you understand better when you should expect runs and re-runs of the derived computations, effect(), and DOM updates.

--

--