Deep dive into the Angular Signals: Part 1

Vlad Sharikov
AngularWave

--

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:

  1. When a signal is changed, it notifies its consumers, marking them as stale
  2. 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):

  1. When a signal is changed, it only updates its version & value
  2. 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:

  1. If the new value is not equal to the current value
  2. Updates signal’s state (pushes the value)
  3. Updates signal’s node version (bumps the integer value) in the signalValueChanged
  4. 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:

  1. The producerAccessed function will be called
  2. 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:

  1. How are nodes created, and when does the computation happen? The computation happens when somebody pulls the value.
  2. 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:

  1. 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
  2. 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!

--

--