Deep dive into the Angular Signals: Part 2

Vlad Sharikov
AngularWave
12 min readNov 21, 2023

--

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:

  1. Makes only needed re-computations (the hasA signal was re-computed because the mySignal version was actually changed)
  2. Avoids extra re-computations (the output signal was not re-computed because the hasA 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.

--

--