Deep dive into the Angular Signals: Part 2
This continues the Deep dive into the Angular Signals: Part 1. There is also an introduction article available here: Introduction to Angular Signals. As I mentioned at the end of the previous text, I am going to check a few more complex examples and check the following questions:
- How does the reactivity graph keep itself consistent automatically, and why is it blazing fast?
- How the reactivity system does not perform extra non-needed calculations?
Let’s figure it out!
How the reactivity system does not perform extra calculations?
Let’s consider a more challenging example:
const mySignal = signal('xyz'); // 1
const hasA = computed(() => mySignal().includes('a')); // 2
const output = computed(() => hasA() ? 'has a' : 'no a'); // 3
console.log(output()); // 4; outputs: no a
mySignal.set('xyz1'); // 5
console.log(output()); // 6; outputs: no a
You can notice that, after the 6th line, the output should not be changed at all. After the mySignal.set('xyz')
call, both hasA
and output
will be marked as stale
during the first phase of the update, but actually, the output
’s value has to stay the same because hasA
stays the same. Do you think the output
's computation function is executed? It should not because hasA
did not change. Let’s change the computed a bit:
const output = computed(() => {
console.log('in computed');
return hasA() ? 'has a' : 'no a'
});
When the 6th line is executed, you don’t see the in computed
log. How does it work? That is another case why saving the seen version is essential while building the reactivity system & travelling for it. There is a mechanism to help the reactivity system decide if the dependencies are outdated and if the current node should or should not be re-computed.
Let’s check the current reactivity graph state after the 3rd line:
First, let’s briefly check what happens on the 4th line and the output()
expression. Here, two major things happen. Firstly, as you already know, the computed signals are getting computed. You can see it as a travelling between nodes. Secondly, dependencies between all nodes are created at this moment. By the way, if you try to find a real-life metaphor, it can be a transport system like a subway. There are stations & hub stations (which are connected to multiple stations), and trains are travelling between them.
Technically, when the output
function is called, the output
signal is accessed. The following code is executed. You are already familiar with it (from the first part of that deep-dive):
const computed = () => {
// Check if the value needs updating before returning it.
producerUpdateValueVersion(node);
// Record that someone looked at this signal.
producerAccessed(node);
if (node.value === ERRORED) {
throw node.error;
}
return node.value;
};
In the producerUpdateValueVersion
function, the reactivity system decides that the signal is UNSET
, saves the current active consumer (the output
signal), and then starts the computation in which it will access the hasA
signal, which is UNSET
, too, so it is getting recomputed too. That happens in the computed
internal function, which is above, too. So, the computed
internal function of two different signals is executed. Before that, the activeConsumer
state is changed again to build the dependency. After that, the mySignal
is accessed (I covered that before, the signalFn
function with a producerAccessed
call). Here, the first dependency is built between the mySignal
and hasA
in the producerAccessed
. After that, the hasA
computation finishes, and you return to its computed function. After that, the producerAccessed
of the hasA
signal is called, and now it works differently than what we observed before. Now, there is an activeConsumer
, so the reactivity system builds another link between the output
and the hasA
signal. After that, the output
computation finishes, and the result value is pulled out of a signal. The graph:
To sum up, you observed how dependencies are built in more complex examples when there is a chain of dependencies.
On the 5th line, you can see the mySignal.set('xyz1')
. You already know that the signal value & version will be updated & since there are no “live” consumers, nobody will be notified. The reactivity graph:
On the 6th line, you see the output()
expression again. At this moment, the output
signal should be re-computed. Remember that in that case, the output signal should have the same value as before because of how the hasA
computed signal is described. The idea is when the mySignal
value is changed to xyz1
the hasA
stays the same because xyz1
does not include a
char. The output
computation function should not be re-computed because its dependency stays the same (the hasA
computation function result is still false
).
Let’s check how it works. So when the code accesses the output
signal, it goes through the computed
internal function and calls the producerUpdateValueVersion
. In that function, it has the following code:
function producerUpdateValueVersion(node) {
// ... part about "live" consumers
if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
node.dirty = false;
return;
}
node.producerRecomputeValue(node);
// After recomputing the value, we're no longer dirty.
node.dirty = false;
}
The producerMustRecompute
method returns false because the value is not COMPUTING
or UNSET
, and the consumerPollProducersForChange
function is executed. Let’s recheck it:
function consumerPollProducersForChange(node) {
assertConsumerNode(node);
for (let i = 0; i < node.producerNode.length; i++) {
const producer = node.producerNode[i];
const seenVersion = node.producerLastReadVersion[i];
if (seenVersion !== producer.version) {
// ^-- this returns false, so this block is skipped
return true;
}
// That is why this code is executed, checking versions
producerUpdateValueVersion(producer);
if (seenVersion !== producer.version) {
return true;
}
}
return false;
}
In the previous example and my remarks about this part, this function returned true
because the first condition was true
. After all, the versions were clearly different. At that moment, I said that we would get back to it, and here we are because the current case is different. Remember that the output
signal has only the hasA
node in the producerNode
array. Now, seenVersion
& producer.version
are the same (the hasA
was not re-computed before, that is why the value & the version are the same in it).
That is why the producerUpdateValueVersion
function is called with the producer
node now. Basically, this function will be executed with each producer of the computed signal. Now, the check is performed on the hasA
signal. There is the consumerPollProducersForChange
function call again. Now, the reactivity system checks the hasA
node and its producer, which is the mySignal
node. It compares versions there & they are different, which means the hasA
node must be recomputed.
If the node must be recomputed, the node.producerRecomputeValue(node)
method of the node is called. You already know it: it will re-comupute the hasA
signal value, set the active consumer, execute the computed
function (passed as an argument when you created the computed
signal), etcetera. After the computation, there is a check if the value is actually changed. It is a part of the producerRecomputeValue
method that happens after the computation. I am talking about that part, let’s remember it:
producerRecomputeValue(node) {
// preparation before the computation
// the computation
// cleaning up the node state after the computation
if (oldValue !== UNSET && oldValue !== ERRORED && newValue !== ERRORED &&
node.equal(oldValue, newValue)) {
// No change to `valueVersion` - old and new values are
// semantically equivalent.
node.value = oldValue;
return;
}
node.value = newValue;
node.version++;
}
Here, the value is not UNSET
. It is not ERRORED
also. That is why the node.equal
method is called. In that case, the old value was xyz
, and the new one was xyz1
. The hasA
signal computed expression is mySignal().includes('a')
. That expression returns false
with the previous value (stored in the oldValue
) and false
with the new value (stored in the newValue
. The condition is true
that is why the code goes inside of the block. It sets the value back to false
(right now, it is COMPUTING
), and this block has an early return. The early return means that the rest of the code outside of the block is not being executed. That means that the version
of the hasA
signal stays the same. That has significant importance. At that moment, the hasA
value was re-computed and remember that it was re-computed because of the call in the consumerPollProducersForChange
function. Let’s get back to it again:
function consumerPollProducersForChange(node) {
assertConsumerNode(node);
for (let i = 0; i < node.producerNode.length; i++) {
const producer = node.producerNode[i];
const seenVersion = node.producerLastReadVersion[i];
if (seenVersion !== producer.version) {
return true;
}
producerUpdateValueVersion(producer);
// ^-- here the hasA signal was re-computed, but its version is still the same
if (seenVersion !== producer.version) {
// ^-- this returns false, so this block is skipped, too
return true;
}
}
return false;
}
The hasA
signal was re-computed, but as I covered before, its version stays the same. There is one more final version comparison. The check shows that the versions are the same:
That is why it is so important to compare values before & after the computation. Let’s sum up the example and its decomposition. The reactivity system is built in a way that it:
- Makes only needed re-computations (the
hasA
signal was re-computed because themySignal
version was actually changed) - Avoids extra re-computations (the
output
signal was not re-computed because thehasA
signal version is the same, and the reactivity system ensured it)
These properties allow Angular’s reactivity system to be blazing fast and robust (everything is up to date with great performance).
How does the reactivity system keep the dependencies up to date?
All magic happens in the producerAccessed
function and in the consumerAfterComputation
function. Let’s check what happens in the following example:
const one = signal(1);
const two = signal(2);
const three = signal(3);
const four = signal(4);
const condition = signal(true);
const output = computed(
() => condition()
? one() + two() + three()
: four()
);
console.log(output());
condition.set(false);
console.log(output());
What is going on in that example? There are two console.log
which access the output
signal. Before accessing it again, the condition
signal value is changed. So, when the condition is true
, the output
signal has four dependencies: one
, two
, three
, and the condition
itself. After the condition
becomes false
, you can see that the dependencies of the output
should be changed. Now they are the four
signals & the condition
itself again.
I said many times that the reactivity system is quite a fast thing. After all, right now, you can use a signal in the component and in its template, and the component change detection will magically work. Also, according to RFCs, there will be new signals: true
components, which likely will have local change detection. The change detection part is really serious business in absolutely every UI framework. That is why it is crucial to keep the dependencies up to date. There is no need to check producers, which are not dependencies anymore, like in the example above. When the condition
is false
, you want the reactivity system to track only the condition
and four
changes. You don’t want to track & check one
, two
, and three
. In this section, I want to focus on how the dependencies are tracked and how they stay current.
You must be pretty confident coding ninjas if you read till that moment, and because you read to that moment, so you can crack the first part easily. A few reactive nodes were created. The current reactivity graph state:
Let’s check the first output()
call. It is when the computed
signal is accessed. During the computation, it will access four signals, meaning the producerAccessed
function will be called four times. Let’s review it again:
export function producerAccessed(node: ReactiveNode): void {
// ...
if (activeConsumer === null) {
// Accessed outside of a reactive context, so nothing to record.
return;
}
// This producer is the `idx`th dependency of `activeConsumer`.
const idx = activeConsumer.nextProducerIndex++;
assertConsumerNode(activeConsumer);
// ...
if (activeConsumer.producerNode[idx] !== node) {
// We're a new dependency of the consumer (at `idx`).
activeConsumer.producerNode[idx] = node;
// ...
}
activeConsumer.producerLastReadVersion[idx] = node.version;
}
This function is called when you access any signal, and it is needed to build dependencies. The activeConsumer
is the output
signal every time because the reactivity system accesses all basic signals while computing the output
signal’s value. Also, before every computation, it prepares the node
, resetting the nextProducerIndex
. That happens in the consumerBeforeComputation
function:
function consumerBeforeComputation(node) {
node && (node.nextProducerIndex = 0);
return setActiveConsumer(node);
}
So, the producerAccessed
is called four times for every basic signal. After that, there are four signals in the producerNode
array of the output
signal.
Now, the condition
value is updated to false
, and the output
signal is reaccessed. Let’s review the computed signal logic again:
producerRecomputeValue(node: ComputedNode<unknown>): void {
if (node.value === COMPUTING) {
// Our computation somehow led to a cyclic read of itself.
throw new Error('Detected cycle in computations.');
}
const oldValue = node.value;
node.value = COMPUTING;
const prevConsumer = consumerBeforeComputation(node);
let newValue: unknown;
try {
newValue = node.computation();
} catch (err) {
newValue = ERRORED;
node.error = err;
} finally {
consumerAfterComputation(node, prevConsumer);
}
if (oldValue !== UNSET && oldValue !== ERRORED && newValue !== ERRORED &&
node.equal(oldValue, newValue)) {
// No change to `valueVersion` - old and new values are
// semantically equivalent.
node.value = oldValue;
return;
}
node.value = newValue;
node.version++;
},
Note there is the consumerAfterComputation
function call after the computation. You will need that in a few moments.
During the computation of the output
(it will be re-computed because the condition
version is different), the producerAccessed
function is called only two times for the condition
and the four
signal. Also, remember that before the computation of the output
node, it was reset, and the nextProducerIndex
is 0
. Let’s review a part of the producerAcessed
method:
function producerAccessed(node) {
// ...
const idx = activeConsumer.nextProducerIndex++;
assertConsumerNode(activeConsumer);
// ...
if (activeConsumer.producerNode[idx] !== node) {
// We're a new dependency of the consumer (at `idx`).
activeConsumer.producerNode[idx] = node;
// ...
}
activeConsumer.producerLastReadVersion[idx] = node.version;
}
In the case of the condition
signal, the activeConsumer.producerNode[idx] !== node
condition returns false
because it compares references and they are the same. The reference in the activeConsumer.producerNode[idx]
is the same as the current node (the condition
). That is why only the producerLastReadVersion
array of the output
is updated there. After that, during the computation of the output
, by executing the condition() ? one() + two() + three() : + four()
expression, the signal accesses the four
signal and the producerAccessed
is called on that node, too. Here, the same check (the activeConsumer.producerNode[idx] !== node
) returns false
because the activeConsumer.producerNode[idx]
is not the four
signal. It holds a reference to the two
signal. The condition returns true
, meaning the previous reference will be overridden, and now it points to the four
signal.
After that, the expression is calculated, and the consumerAfterComputation
is called. Let’s revise it again:
// Truncate the producer tracking arrays.
for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) {
node.producerNode.pop();
node.producerLastReadVersion.pop();
node.producerIndexOfThis.pop();
}
The node.nextProducerIndex
is 2
, and the node.producerNode.length
is 4
. This means that the body of the loop will be executed two times. You can see that it removes elements from the end of the following arrays: node.producerNode
, node.producerLastReadVersion
, and node.producerIndexOfThis
. That is how dependencies which are not needed anymore are removed. That is how the reactivity system keeps the dependencies up to date on the fly. Also, that is another example of how it travels between nodes. Let’s check what the reactivity graph looks like and what was changed on it (check on red marks):
Also, note that the dependencies will be updated under the hood even in the case when the version
of a node was not updated (the value stayed the same after the computation). That is very important in the case of “live” consumers, which are part of the next article.
Conclusion
This article and the first part of that deep dive is an excellent start to dig deep into the Angular reactivity system & its underlying structures & logic connecting them. In the next part, I will break down how Signals work in templates of components. Besides that, there will be much more, and that is pretty important stuff: the framework’s developers will introduce new signals: true
components that will work differently compared to what we have right now with zone.js
. According to RFCs, the local component’s change detection will be possible for such components. Also, inputs-as-signals & queries-as-signals (view child, content child, and others), new application hooks are under development.
That is it for now. I would like to say thank you to Roman Sedov & Alex Inkin for their help with reviewing this article, that was a significant effort. Keep an eye out for future updates, as I intend to provide coverage on the upcoming features released by Angular as well.