How Do Angular Signals Work Under the Hood?

by Lourens — 12 minutes

Under the hood, Angular signals function as nodes in a reactive graph. The graph uses the observer pattern. Provider nodes notify consumers of value changes. Angular components' views are nodes that observe the signals referred to in their template. So Angular knows which exact view to update.

1. What are Angular Signals?

With Angular 16 released on May 3rd, 2023 we got signals. They're used to add reactivity to Angular as a state tracking and change detection system that helps Angular optimize rendering updates. You use signals to wrap any data and use these wrappers to notify any other part of your code when a change has happened to the wrapped data.

The official documentation explains how to use them really well. But to show you the simplicity of using them I'll briefly summarize them.

A. How to use Signals

There are 2 types of signals, Writable and Computed signals.

i. Writable Signals

Writable signals (type WritableSignal) can be created with an initial value like this const mySignal = signal({ foo: "bar" }) with any type and even nullish values. After creating a signal you can either read, set, update, or mutate:

  • read them as a getter like mySignal()

  • set them to a completely new value like mySignal.set({ foo: "bar1" })

  • update with a new value based on the current one like mySignal.update(currentValue => { foo: currentValue.foo + "1"})

  • mutate the current value without creating a new one like mySignal.mutate(currentValue => currentValue.foo = "bar1")

ii. Computed Signals

Computed signals can not be directly set, updated, or mutated. They can only be read as a getter. Computed signals derive their value from other signals. The derivation is specified when the computed signal is initialized like this const myComputedSignal = computed(() => "mySignal.foo is now: " + mySignal().foo).

  • Whenever mySignal is changed then myComputedSignal will be updated too.

  • As an example, we can now use the string value returned from myComputedSignal in our HTML which will then be rerendered every time myComputedSignal updates.

  • We could have multiple signals (writable or computed) as dependencies of our computed signal, similar to the useEffect dependency array in React, by adding more signal getters into the derivation function.

  • If we want to use the values of signalA and signalB in our computed signalC, but only want signalC to be updated when signalB changes, then we should use signalA with the untracked method like this untracked(signalA).

iii. Effects

Next to writable and computed signals, there are also Effects. These are functions that run at least once and then any time any of their signal dependencies change. They look like this effect(() => { console.log(myComputedSignal()) }). These can be useful for logging for example. It's not recommended to make any state changes with these.

If you know this, you know around 99% about how to use signals. But let's now look into how they work under the hood.

Extra note: In Angular 16.2.0 you no longer need to set signals: true in your component decorators to make your components signal-based. This might be confusing if you're looking at older articles/RFC/discussions etc.

2. How do Angular Signals work under the hood?

A primary mechanism of frameworks like Angular is synchronization between the rendered UI and the applications data model. This is referred to as reactivity. Before signals, Angular used zone.js for reactivity.

I'm not going into detail on how zone.js works, but a crucial challenge that comes with it is that it doesn't provide fine-grained information about data changes. Zone.js can be used to notify the framework that something might have happened, but it can't give information on what has happened.

This is where signal-based reactivity brings improvements. This is not a new concept. Other frameworks like Preact, Solid, and Vue have similar reactivity systems. Let's now dive into how Angular has implemented signals. The code snippets I've used are cherry picked versions from the Angular 16.2.0 source code.

Extra note: Zone.js is still supported in Angular 16.

A. API

In this section, I'll first explain the graph that Angular's signal system is based on. Then I'll explain how signals and effects tap into this graph. Finally, I'll briefly cover how Angular incorporates this signal system into its rendering system.

i. ReactiveNode Graph

The signal system is based on the reactive node graph and uses an observer pattern. All signals are implementations of the abstract ReactiveNode class. A reactive node can be a producer, consumer, or both.

Producers are nodes that produce values and can be depended upon by consumer nodes. Consumers are nodes that depend on the values of producers and are notified when those values might have changed.

A reactive node has weak references to all its consumers and all its producers. These weak references are held in a consumer list and a producer list of ReactiveEdge objects. A ReactiveEdge is a combination of node ID and a weak reference to this node.

export abstract class ReactiveNode {
...
  private readonly producers = new Map<number, ReactiveEdge>();

  private readonly consumers = new Map<number, ReactiveEdge>();
...
}

A WeakRef object lets you hold a weak reference to another object, without preventing that object from getting garbage-collected.

interface ReactiveEdge {
  readonly producerNode: WeakRef<ReactiveNode>;

  readonly consumerNode: WeakRef<ReactiveNode>;
...
}

So there's is a reactive dependency graph of reactive nodes. The graph can have a single active consumer. The active consumer is set before any consumer consumes from their dependencies. This way, a provider knows that when it is being accessed, the active consumer should be on its consumer list.

Each reactive node has a trackingVersion and a valueVersion. These are both monotonically increasing counters. The trackingVersion represents the version of this Consumer's dependencies. The valueVersion represents the version of this Producer's value which increases when the value of this Producer semantically changes.

export abstract class ReactiveNode {
...  
  protected trackingVersion = 0;

  protected valueVersion = 0;
...
}

Reactive nodes have a defined method called consumerPollProducersForChange. Consumer nodes can use this to poll for changes in their dependencies. It basically iterates over its list of producers to compare the last valueVersion it has seen of that producer to the current valueVersion of the producer to determine whether a value change has occurred.

export abstract class ReactiveNode {
...
  protected consumerPollProducersForChange(): boolean {
    for (const [producerId, edge] of this.producers) {
      const producer = edge.producerNode.deref();
      
      if (producer == null || edge.atTrackingVersion !== this.trackingVersion) {
        // This dependency edge is stale, so remove it.
        this.producers.delete(producerId);
        producer?.consumers.delete(this.id);
        continue;
      }

      if (producer.producerPollStatus(edge.seenValueVersion)) {
        // One of the dependencies reports a real value change.
        return true;
      }
    }

    return false;
  }
...
}

Next to that, reactive nodes also have a defined method called producerMayHaveChanged. Producer nodes can use this to notify all consumers that their value may have changed. It does this by iterating over the list of consumers and calling the abstract method onConsumerDependencyMayHaveChanged. This means that a derived class of the ReactiveNode has to implement that method. In the next parts, I'll show you how signals and effects implement this.

export abstract class ReactiveNode {
...
  protected producerMayHaveChanged(): void {
    // Prevent signal reads when we're updating the graph
    const prev = inNotificationPhase;
    inNotificationPhase = true;
    try {
      for (const [consumerId, edge] of this.consumers) {
        const consumer = edge.consumerNode.deref();

        if (consumer == null || consumer.trackingVersion !== edge.atTrackingVersion) {
          this.consumers.delete(consumerId);
          consumer?.producers.delete(this.id);
          continue;
        }

        consumer.onConsumerDependencyMayHaveChanged();
      }
    } finally {
      inNotificationPhase = prev;
    }
  }
...
}

ii. Writable and Computed Signals

A signal is an implementation of the abstract ReactiveNode class. There are two distinct signal implementations, these are the writable signal and the computed signal.

class WritableSignalImpl<T> extends ReactiveNode { ... }
class ComputedImpl<T> extends ReactiveNode { ... } 

A writable signal is only a producer. It only changes by direct writes and doesn't depend on any other reactive values, so its value will always be up to date. Writable signals can't consume any other signals, so they leave their onConsumerDependencyMayHaveChanged implementation empty.

For writable signals, reading is done through getters, and mutations are done through the mutation methods.

When a writable signal is read it will return the current value and will make sure that the currently active consumer is part of its consumer list. Next to that, it will remember the valueVersion that this consumer has seen it at and it will also remember the trackingVersion of this consumer at this time.

When the mutation methods(set, update, mutate) of writable signals are called and actually change the value they all end up calling the producerMayHaveChanged method that notifies all its consumers that their value changed.

class WritableSignalImpl<T> extends ReactiveNode {
...
  set(newValue: T): void {
    if (!this.producerUpdatesAllowed) {
      throwInvalidWriteToSignalError();
    }
    if (!this.equal(this.value, newValue)) {
      this.value = newValue;
      this.valueVersion++;
      this.producerMayHaveChanged();

      postSignalSetFn?.();
    }
  }


  update(updater: (value: T) => T): void {
    if (!this.producerUpdatesAllowed) {
      throwInvalidWriteToSignalError();
    }
    this.set(updater(this.value));
  }

  mutate(mutator: (value: T) => void): void {
    if (!this.producerUpdatesAllowed) {
      throwInvalidWriteToSignalError();
    }
    // Mutate bypasses equality checks as it's by definition changing the value.
    mutator(this.value);
    this.valueVersion++;
    this.producerMayHaveChanged();

    postSignalSetFn?.();
  }
...
}

Computed signals, which are both consumers and providers, are initialized with a computation method. Every time a dependency of a computed signal might have changed, the computed signal is set to stale, and all its consumers are notified.

Then every time a dependent consumer checks if the computed signal is stale and it turns out to be true the value is recomputed based on the computation method given when the computed signal was created.

class ComputedImpl<T> extends ReactiveNode {
...
  protected override onConsumerDependencyMayHaveChanged(): void {
    if (this.stale) {
      return;
    }

    this.stale = true;

    this.producerMayHaveChanged();
  }
...
}

Computed signals set the active consumer of the graph right before recomputing and then set the active consumer back to the previous one since they're now done consuming. This is how the currently active consumer is being tracked and how its dependencies know that this computed signal should be in their list of consumers.

class ComputedImpl<T> extends ReactiveNode {
...
  private recomputeValue(): void {
    if (this.value === COMPUTING) {
      throw new Error('Detected cycle in computations.');
    }

    const oldValue = this.value;
    this.value = COMPUTING;

    this.trackingVersion++;
    const prevConsumer = setActiveConsumer(this);
    let newValue: T;
    try {
      newValue = this.computation();
    } catch (err) {
      newValue = ERRORED;
      this.error = err;
    } finally {
      setActiveConsumer(prevConsumer);
    }

    this.stale = false;

    if (oldValue !== UNSET &amp;&amp; oldValue !== ERRORED &amp;&amp; newValue !== ERRORED &amp;&amp;
        this.equal(oldValue, newValue)) {
      this.value = oldValue;
      return;
    }

    this.value = newValue;
    this.valueVersion++;
  }
...
}

iii. Effects

When the effect method is used in Angular, a Watch object is created. Watch is a ReactiveNode implementation and acts as a reactive expression that can be scheduled to run when any of its dependencies change. Watches are consumers and don't hold any value.

When a watch is executing its reactive expression it will be set as the active consumer in the ReactiveNode graph. Similar to when a computed signal is computing.

An effect watch is not directly run when a dependency changes. Instead, it's scheduled to be rerun in the EffectManager. The EffectManager tracks all effects registered within a given application and runs them with the flush method. The flush method is used in the refreshView and detectChangesInternal methods in Angular's Render3's change_detection.ts

export class EffectManager {
...
  private all = new Set<Watch>();
  private queue = new Map<Watch, Zone|null>();

  create(
      effectFn: (onCleanup: (cleanupFn: EffectCleanupFn) => void) => void,
      destroyRef: DestroyRef|null, allowSignalWrites: boolean): EffectRef {
    const zone = (typeof Zone === 'undefined') ? null : Zone.current;
    const watch = new Watch(effectFn, (watch) => {
      if (!this.all.has(watch)) {
        return;
      }

      this.queue.set(watch, zone);
    }, allowSignalWrites);

    this.all.add(watch);

    // Effects start dirty.
    watch.notify();

    let unregisterOnDestroy: (() => void)|undefined;

    const destroy = () => {
      watch.cleanup();
      unregisterOnDestroy?.();
      this.all.delete(watch);
      this.queue.delete(watch);
    };

    unregisterOnDestroy = destroyRef?.onDestroy(destroy);

    return {
      destroy,
    };
  }

  flush(): void {
    if (this.queue.size === 0) {
      return;
    }

    for (const [watch, zone] of this.queue) {
      this.queue.delete(watch);
      if (zone) {
        zone.run(() => watch.run());
      } else {
        watch.run();
      }
    }
  }
...
}

Extra note: The EffectManager's flush method is a good example of how zone.js is still supported.

B. Rendering

In Angular, a component is the most fundamental building block of the UI. An Angular application is a tree of these components. A component is always associated with a template.

Angular components define a set of screen elements that Angular can choose among and modify according to your program logic and data. These are called views.

When an Angular template reads a signal, its view becomes the active consumer of the ReactiveNode graph. The signal recognizes this view as a consumer and notifies the view on value changes. Now Angular knows when to rerender this specific view.

3. Useful resources

4. What changed for Signals in Angular 17?

I wrote this article in 2023 and based my findings on Angular 16. Since then Angular 17 has been released and with it came some changes to how Angular Signals work under the hood, but it's mostly refactoring with the core principles intact.

Here is the new implementation of signals in 17.1.2: https://github.com/angular/angular/tree/17.1.2/packages/core/primitives/signals

meerdivotion

Cases

Blogs

Event