Deep dive into the Angular Signals: Part 1
Hey again! If you haven’t read my first article about Angular Signals, you should check it out here: Introduction to Angular Signals. This was the article one in a cycle about the Angular Signals system.
Last time, we discussed the basics: what reactivity is, how Angular Signals work at a high level, some simple examples, and what’s happening under the hood. But there wasn’t a lot of deep-dive stuff out there and in other articles, why not dig deeper?
So, what am I covering this time? I will get into the internal structures of Angular’s reactivity system. We’ll see how they work and how they change over time. Expect to see a bunch of code and diagrams — this is the deep dive, after all. By the end, you’ll have a way better grasp of what exactly is going on inside Angular Signals. So, the target audience of this article is critical-thinking and detail-oriented people who want to understand the internals of the Angular reactivity system and how it is built.
What things will be covered in this article?
I am going to touch the following topics:
- Revisit what core structures are used under the hood
- How does the Angular reactivity system work in detail?
- How does the reactivity system build the dependency graph?
- 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?
Besides that, please pay attention to the schemes I am adding alongside the text. While explaining what is happening in the code, I will attach the reactivity graph states at different moments.
What core structures are used under the hood?
You might remember I covered that in the previous article. The Angular team rewrote the Angular Signals completely. Now, it works entirely differently under the hood, and other structures are used. Basically, it is still the ReactiveNode
, SignalNode
, and ComputedNode
, but Angular manages it differently. Now, there are “live” and “non-live” signals. In that article, I will cover only “non-live” signals. They are used in the basic cases without Angular. In the following article, I will talk about “live” signals. Long story short, before the refactoring, the Angular’s reactivity system worked like that:
- When a signal is changed, it notifies its consumers, marking them as stale
- When something reads the computed value, if it is stale, it is being recomputed (and all its dependencies, if they are stale)
After the refactoring, it works a bit differently (right now, I am considering only “non-live” cases):
- When a signal is changed, it only updates its version & value
- When something reads the computed value, it checks if its producers have any changes, and if yes, it recomputes itself (and all needed dependencies)
How does everything work under the hood based on those structures?
Now, let’s dive into details and see how the reactivity system is built, and its elements talk to each other when you’re messing around with the public API. I’ll walk you through the inner workings with some hands-on examples. We’ll start simple and ramp up the complexity as we go along.
Basic signal:
const mySignal = signal('1');
mySignal();
mySignal.set('2');
mySignal.set('3');
mySignal.set('4');
mySignal();
When you call signal('1')
internally, a new SIGNAL_NODE
is created.
Here is what the graph looks like after the 1st line. It has one node.
The signal
function returns another function with a set of methods to modify the signal’s state or push the state into the signal. You can call the returned function itself to pull the value out of the signal.
export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T> {
const node: SignalNode<T> = Object.create(SIGNAL_NODE);
node.value = initialValue;
options?.equal && (node.equal = options.equal);
function signalFn() {
producerAccessed(node);
return node.value;
}
signalFn.set = signalSetFn;
signalFn.update = signalUpdateFn;
signalFn.mutate = signalMutateFn;
signalFn.asReadonly = signalAsReadonlyFn;
(signalFn as any)[SIGNAL] = node;
return signalFn as WritableSignal<T>;
}
// ...
function signalSetFn<T>(this: SignalFn<T>, newValue: T) {
const node = this[SIGNAL];
if (!producerUpdatesAllowed()) {
throwInvalidWriteToSignalError();
}
if (!node.equal(node.value, newValue)) {
node.value = newValue;
signalValueChanged(node);
}
}
Here is what the signal’s set
is doing:
- If the new value is not equal to the current value
- Updates signal’s state (pushes the value)
- Updates signal’s node version (bumps the integer value) in the
signalValueChanged
- Notifies it’s live consumers in the
signalValueChanged
In the current example, there are no live consumers, so in that case, nobody will be notified. Jumping ahead, it is needed for Angular components & cases when you use a signal in a template. That is a special case, and it requires live consumers (because when such signals are changed, you need to mark the view as dirty, but it is a long story for another day).
A few set
calls will update value
& version
properties. Here is the updated reactive graph:
That is pretty much it for the basic signal. Let’s discuss an example when you have a signal which depends on another signal.
Lazy evaluation & automatic dependency graph
Let’s consider a harder example with a computed signal:
const mySignal = signal(1); // 1
const derivedSignal = computed(() => mySignal() ** 2); // 2
console.log(derivedSignal()); // 3; outputs: 1
Let’s break it down. The 1st line was covered in the basic signal’s example. It works the same. The first reactive node is created.
The 2nd line creates another reactive node: COMPUTED_NODE
. That happens in the computed
function:
export function computed<T>(computation: () => T, options?: CreateComputedOptions<T>): Signal<T> {
const node: ComputedNode<T> = Object.create(COMPUTED_NODE);
node.computation = computation;
options?.equal && (node.equal = options.equal);
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;
};
(computed as any)[SIGNAL] = node;
return computed as any as Signal<T>;
}
The computation function is passed into the computed signal. Also, it is possible to specify the comparison function for the computed and for the basic signal. That function will be used internally later. Right now, nothing is calculated when you create the computed signal & there are no dependencies in the graph for now (there are two reactive nodes but no links, but we are getting to it). The derivedSignal
has Symbol(UNSET)
value when it’s created. It is not computed right away. The value is going to be computed only when somebody pulls the value explicitly. Here is what the reactivity graph looks like after this step:
The 3rd line is where the interesting part begins, and here is when the actual computation happens. Let’s break it down. Here is the function that is called when you access the computed signal again:
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;
};
Let’s check the producerUpdateValueVersion
function:
export function producerUpdateValueVersion(node: ReactiveNode): void {
if (consumerIsLive(node) && !node.dirty) {
return;
}
if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
// None of our producers report a change since the last time they were read, so no
// recomputation of our value is necessary, and we can consider ourselves clean.
node.dirty = false;
return;
}
node.producerRecomputeValue(node);
// After recomputing the value, we're no longer dirty.
node.dirty = false;
}
The “live” part is not our case right now & I don’t cover it in that article. The reactivity system checks if the computed node should be recomputed. The producerMustRecompute
method is called:
producerMustRecompute(node: ComputedNode<unknown>): boolean {
// Force a recomputation if there's no current value, or if the current value is in the process
// of being calculated (which should throw an error).
return node.value === UNSET || node.value === COMPUTING;
},
The initial value of the computed signal is UNSET
, so this function will return true. Given the condition in the caller function, the whole condition is false because the logical AND operator is used. That is why the second part of the condition is not even called during the first access of a computed signal. That is why I am going to cover the consumerPollProducersForChange
method later.
Now, let’s check the producerRecomputeValue
method:
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++;
}
There is a check to prevent cyclic reads. That is possible by saving the current value and setting the COMPUTING
value to the signal in the following line. Notice how the value has been updated on the graph (here and further, the green mark indicates “updated”):
The node is prepared for the calculation in the consumerBeforeComputation
function:
export function consumerBeforeComputation(node: ReactiveNode|null): ReactiveNode|null {
node && (node.nextProducerIndex = 0);
return setActiveConsumer(node);
}
// ..
let activeConsumer: ReactiveNode|null = null;
let inNotificationPhase = false;
export function setActiveConsumer(consumer: ReactiveNode|null): ReactiveNode|null {
const prev = activeConsumer;
activeConsumer = consumer;
return prev;
}
That is interesting and important. At first, the nextProducerIndex
is reset to 0. It is vital for the reactivity system to build dependencies between nodes (you will see that later in complex examples). After that, the setActiveConsumer
is called, which sets the current active consumer variable. That variable will be checked just a few moments later to build a dependency. Also, the previous value of the activeConsumer
value is returned and saved. It will be used to set back after the computation.
The next crucial part is the node.computation()
line when the actual computation starts. What does it mean? The computation function is () => mySignal() ** 2
, and it is going to be invoked right now. That means that the mySignal
will be accessed. When a basic signal is accessed, the following function is executed:
function signalFn() {
producerAccessed(node);
return node.value;
}
Which means two things:
- The
producerAccessed
function will be called - And the value will be pulled out of a signal
Let’s check the producerAccessed
function (I omit dev mode assertions & the “live” consumer logic):
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;
}
// ...
function assertConsumerNode(node: ReactiveNode): asserts node is ConsumerNode {
node.producerNode ??= [];
node.producerIndexOfThis ??= [];
node.producerLastReadVersion ??= [];
}
At first, it checks if the activeConsumer
is set. It is set because, before the computation, the setActiveConsumer
was called. It would not be set, for example, if you have a very simple case like const x = signal(); x()
. In that case, you don’t have activeConsumer
, and this function will do nothing & the value will be returned immediately. Currently, there is an active consumer, the derivedSignal
node.
After that, remember the nextProducerIndex
, which was prepared before the calculation. It is used here to set up the derivedSignal
producer node. Also, there is the assertConsumerNode
call that sets up the needed reactive node structure if it isn’t set up yet.
After that, there is a condition: activeConsumer.producerNode[idx] !== node
. Remember, the activeConsumer
is currently the derivedSignal
node. The condition checks if the idx
element of the activeConsumer.producerNode
array is the current node (mySignal
). That is not true for the first run because the producerNode
is empty. Since it is empty, the node
is added to the producerNode
array.
After that, the reactivity system puts the current mySignal
version to the producerLastReadVersion
array of the derivedSignal
node. It will be needed for further checks.
At that moment, the link between two nodes was created & the mySignal
value was pulled and returned to the computation function so the expression is 1 ** 2
, which results in 2
. That value is returned as a result of the computation. The updated graph:
Okay, I will remind you that we are still investigating the producerRecomputeValue
method of the derivedSignal
computed node. I was covering the newValue = node.computation();
part, the value was saved to the newValue
variable. The next stop is the consumerAfterComputation
function:
export function consumerAfterComputation(
node: ReactiveNode|null, prevConsumer: ReactiveNode|null): void {
setActiveConsumer(prevConsumer);
// ...
// Truncate the producer tracking arrays.
for (let i = node.nextProducerIndex; i < node.producerNode.length; i++) {
node.producerNode.pop();
node.producerLastReadVersion.pop();
node.producerIndexOfThis.pop();
}
}
This function returns the active consumer if there was some & cleans the state of the reactive node. The second part is needed in complex cases. Imagine you have a computed signal with several dependencies. Imagine a signal like that: computed(() => one() ? two() + three() + four() : five())
. This signal will have either one
& two + three + four
as dependencies or one
& five
depending on the one
value. When the value is switched, the reactivity system wants to clean up unneeded dependencies. That is why the code at the end of the consumerAfterComputation
function is needed.
Let’s break down the latest part of the computation process. The rest of the consumerAfterComputation
is:
// <-- We stopped here
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++;
The oldValue
is UNSET
, so the condition returns false
immediately after the first expression is calculated. At the end, the derivedSignal
value & version are updated.
We finished the producerRecomputeValue
. Let’s get back to the producerUpdateValueVersion
function. At the end of it, the derivedSignal
is marked as not dirty, and we are getting back to the computed
function:
const computed = () => {
// Check if the value needs updating before returning it.
producerUpdateValueVersion(node);
// <-- we are here
// Record that someone looked at this signal.
producerAccessed(node);
if (node.value === ERRORED) {
throw node.error;
}
return node.value;
};
After that, the producerAccessed
method is called, but at that moment, the activeConsumer
is not set, which is why it does not build any new dependencies. So, the value is returned.
I remind you that I am talking about the following example:
const mySignal = signal(1); // 1
const derivedSignal = computed(() => mySignal() ** 2); // 2
console.log(derivedSignal()); // 3; outputs: 1
Here is the updated reactivity graph in the end:
Let’s make a first summary of what happened in the first 3 lines of the example. We talked about:
- How are nodes created, and when does the computation happen? The computation happens when somebody pulls the value.
- How and when dependencies between nodes are built? The dependencies are built while doing computations within accessing the reactivity nodes.
Why are dependencies critical?
The dependencies between nodes are very important. Besides the fact that they allow the reactivity system to have a notification system, they allow the reactivity system to check if the value is changed, and that happens at lightning speed. It will be quick even in the case of very big objects with a lot of properties with deep nesting. The comparison speed will be the same in the case of the very complex object & the simplest number & it is incredibly fast.
Let’s extend the previous example a bit:
const mySignal = signal(1); // 1
const derivedSignal = computed(() => mySignal() ** 2); // 2
console.log(derivedSignal()); // 3; outputs: 1
// <-- two more instructions are added
mySignal.set(2); // 4
console.log(derivedSignal()); // 5; outputs: 4
During the set
method call, the mySignal
value & version is updated. Remember that the dependency between nodes already exists. The signalSetFn
is called, and it updates the value & calls the signalValueChanged
:
function signalValueChanged(node) {
node.version++;
producerNotifyConsumers(node);
postSignalSetFn?.();
}
The updated graph:
Now, let’s break down what is happening in the last line of the example. The computed
internal function is accessed, leading to the execution of the producerUpdateValueVersion
function. Now let’s check that block again:
if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
node.dirty = false;
return;
}
node.producerRecomputeValue(node);
The value is not UNSET
anymore. It is 1
now. So, the first part of the condition is true
, which means the next expression is executed with the consumerPollProducersForChange
function:
export function consumerPollProducersForChange(node: ReactiveNode): boolean {
assertConsumerNode(node);
// Poll producers for change.
for (let i = 0; i < node.producerNode.length; i++) {
const producer = node.producerNode[i];
const seenVersion = node.producerLastReadVersion[i];
// First check the versions. A mismatch means that the producer's value is known to have
// changed since the last time we read it.
if (seenVersion !== producer.version) {
return true;
}
// The producer's version is the same as the last time we read it, but it might itself be
// stale. Force the producer to recompute its version (calculating a new value if necessary).
producerUpdateValueVersion(producer);
// Now when we do this check, `producer.version` is guaranteed to be up to date, so if the
// versions still match then it has not changed since the last time we read it.
if (seenVersion !== producer.version) {
return true;
}
}
return false;
}
The assertConsumerNode
is called, and I already covered it. It is needed to safely work with properties like producerNode
and producerLastReadVersion
. The consumerPollProducersForChange
returns a boolean. It checks each producerNode
of the current node and checks the seenVersion
and the current producer
version. The node.producerLastReadVersion[i]
or seenVersion
was set right after the moment when the dependency between nodes was created on the 3rd line of the example. In that case, versions are different, meaning the dependency is outdated, and the signal must be recomputed. If versions were the same, there is no need to re-compute the current signal because the dependencies are the same. Here is what is checked in this step (I marked what is being compared with red circles, you will see such marks further, too):
By the way, that is why the check is so fast. The reactivity system compares plain numbers instead of deepEqual
or stuff. Also, right now, I am skipping the rest part of the consumerPollProducersForChange
. It is needed for more complex cases, and I will cover that in the next section.
Since the consumerPollProducersForChange
showed that the current signal is outdated (because one of its dependencies has a version different to what was seen before, during the previous access), the node re-computed in the node.producerRecomputeValue
method. You already know the computation process in the function. Don’t forget that the producerLastReadVersion
array of the output
signal is updated in the producerAccessed
(while accessing the mySignal
during the computation):
Let’s check the latest part of the producerRecomputeValue
method again because now it works differently:
// ... part of the producerRecomputeValue method
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++;
Now, the value is not UNSET
. That is why the node.equal
is called, which I told you before. The new value after the computation is 4
, which is not equal to the oldValue
(it is 1
). That means that there is actual change, and both the value
& version
is updated. That part of the code is very important because there can be more complex cases when the value is the same as before. In that case, the reactivity node version should not be changed. Here is the updated graph:
To sum up, in that section, I covered:
- Why the dependencies are essential? To check if any dependency of a computed signal is updated → the reactivity system understands that the current signal must be re-computed
- How exactly does the internal structure of reactivity nodes help to make checks incredibly fast? By comparing plain numbers, it works at warp speed.
The article takes about ~15 minutes to read at this moment, so I split it into two pieces. In that article, I covered simple examples, and we discovered how the Angular reactivity system works internally together. In the next part, I am going to decompose 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?
Stay tuned!