Angular Signals Implementation

A signal is a value that is “reactive,” meaning it can notify interested consumers when it changes. There are many different implementations of this concept. In this article we will look at the implementation of the Angular command, delve into the code and try to figure out exactly how the signal algorithm works from the inside.

In this article we will build directly on the Angular codebase – https://github.com/angular/angular/tree/main.

Briefly about signals

Signals in Angular these are functions without arguments (() => T). When executed, they return the current value of the signal.

    /**
     * Symbol used to tell `Signal`s apart from other functions.
     *
     * This can be used to auto-unwrap signals in various cases, or to auto-wrap non-signal values.
     */
    export const SIGNAL = /* @__PURE__ */ Symbol('SIGNAL');

    /**
     * A reactive value which notifies consumers of any changes.
     *
     * Signals are functions which returns their current value. To access the current value of a signal,
     * call it.
     *
     * Ordinary values can be turned into `Signal`s with the `signal` function.
     */
    export type Signal<T> = (() => T) & {
        [SIGNAL]: unknown;
    };

Signals are executed without side effects, although they may lazily recalculate intermediate values ​​(lazy memoization). Certain contexts (such as template expressions) can be reactive. In such contexts, executing the signal will return a value and also register the signal as a dependency of the context in question. The owner of the context will be notified if any of its signal dependencies create a new value. This context and get function mechanism allows you to automatically and implicitly track context signal dependencies. Users do not need to declare arrays of dependencies, and a particular context's set of dependencies does not need to remain static between executions.

Right off the bat, to code!

Let's go directly to implementation signals. To go deeper and understand the essence, we will consider the signal algorithm step by step.

    /**
     * Create a `Signal` that can be set or updated directly.
     */
    export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T> {
        performanceMarkFeature('NgSignals');
        const signalFn = createSignal(initialValue) as SignalGetter<T> & WritableSignal<T>;
        const node = signalFn[SIGNAL];
        if (options?.equal) {
            node.equal = options.equal;
        }

        signalFn.set = (newValue: T) => signalSetFn(node, newValue);
        signalFn.update = (updateFn: (value: T) => T) => signalUpdateFn(node, updateFn);
        signalFn.asReadonly = signalAsReadonlyFn.bind(signalFn as any) as () => Signal<T>;
        if (ngDevMode) {
            signalFn.toString = () => `[Signal: ${signalFn()}]`;
        }
        return signalFn as WritableSignal<T>;
    }

When defining a signal, it is of course possible to specify the type of value to be stored. It is also possible to specify the initial value initialValue and additional optional parameters optionscontaining (at the moment) the only parameter equal. equal used to determine whether the new value provided is the same as the value of the current signal.
If the equality function determines that 2 values ​​are equal, it:

  1. blocks updating the signal value;

  2. allows changes to propagate.

    /**
     * Options passed to the `signal` creation function.
     */
    export interface CreateSignalOptions<T> {
        /**
         * A comparison function which defines equality for signal values.
         */
        equal?: ValueEqualityFn<T>;
    }

    /**
     * A comparison function which can determine if two values are equal.
     */
    export type ValueEqualityFn<T> = (a: T, b: T) => boolean;

Return value – WritableSignal – inherits type Signalreturning the value of the signal when called, and also adds a number of new methods – set, update, asReadonly:

    /** Symbol used distinguish `WritableSignal` from other non-writable signals and functions. */
    export const ɵWRITABLE_SIGNAL = /* @__PURE__ */ Symbol('WRITABLE_SIGNAL');

    /**
     * A `Signal` with a value that can be mutated via a setter interface.
     */
    export interface WritableSignal<T> extends Signal<T> {
        [ɵWRITABLE_SIGNAL]: T;

        /**
         * Directly set the signal to a new value, and notify any dependents.
         */
        set(value: T): void;

        /**
         * Update the value of the signal based on its current value, and
         * notify any dependents.
         */
        update(updateFn: (value: T) => T): void;

        /**
         * Returns a readonly version of this signal. Readonly signals can be accessed to read their value
         * but can't be changed using set or update methods. The readonly signals do _not_ have
         * any built-in mechanism that would prevent deep-mutation of their value.
         */
        asReadonly(): Signal<T>;
    }

The first (auxiliary) step in implementing signals is the function performanceMarkFeaturewhich takes a string as input. This function is a wrapper over the method mark from Performance Web API – Allows you to create a timestamp to track the performance of the signal generation function.

    const markedFeatures = new Set<string>();

    // tslint:disable:ban
    /**
     * A guarded `performance.mark` for feature marking.
     *
     * This method exists because while all supported browser and node.js version supported by Angular
     * support performance.mark API. This is not the case for other environments such as JSDOM and
     * Cloudflare workers.
     */
    export function performanceMarkFeature(feature: string): void {
        if (markedFeatures.has(feature)) {
            return;
        }
        markedFeatures.add(feature);
        performance?.mark?.('mark_feature_usage', {detail: {feature}});
    }

Signal as an Angular primitive

Function createSignal creates a specific type of signal that tracks the stored value. In addition to providing the function of retrieving the value of a signal, these signals can be associated with additional APIs to change the value of the signal (along with notifying any dependent objects of the change). Consider the function createSignal more details:

   export interface SignalNode<T> extends ReactiveNode {
        value: T;
        equal: ValueEqualityFn<T>;
    }

    export type SignalBaseGetter<T> = (() => T) & {readonly [SIGNAL]: unknown};

    // Note: Closure *requires* this to be an `interface` and not a type, which is why the
    // `SignalBaseGetter` type exists to provide the correct shape.
    export interface SignalGetter<T> extends SignalBaseGetter<T> {
        readonly [SIGNAL]: SignalNode<T>;
    }

    /**
     * The default equality function used for `signal` and `computed`, which uses referential equality.
     */
    export function defaultEquals<T>(a: T, b: T) {
        return Object.is(a, b);
    }

    // Note: Using an IIFE here to ensure that the spread assignment is not considered
    // a side-effect, ending up preserving `COMPUTED_NODE` and `REACTIVE_NODE`.
    // TODO: remove when https://github.com/evanw/esbuild/issues/3392 is resolved.
    export const SIGNAL_NODE: SignalNode<unknown> = /* @__PURE__ */ (() => {
        return {
            ...REACTIVE_NODE,
            equal: defaultEquals,
            value: undefined,
        };
    })();

    /**
     * Create a `Signal` that can be set or updated directly.
     */
    export function createSignal<T>(initialValue: T): SignalGetter<T> {
        const node: SignalNode<T> = Object.create(SIGNAL_NODE);
        node.value = initialValue;
        const getter = (() => {
            producerAccessed(node);
            return node.value;
        }) as SignalGetter<T>;
        (getter as any)[SIGNAL] = node;
        return getter;
    }

What's going on here?

First, several interfaces are defined at once:

  1. SignalNode inherited from ReactiveNode (discussed below) and has 2 required fields – value And equalwe reviewed them above;

  2. SignalGetter inherited from SignalBaseGetterwhich is an analogue of the type Signal except that it is readonly and also has a readonly field [SIGNAL] type SignalNode.

Secondly, the object is defined SIGNAL_NODE type SignalNode<unknown>in which default values ​​are set value in undefined and equal into a function defaultEqualswhich compares signal values ​​via Object.is and also contains the object's default values REACTIVE_NODE (also discussed below).

Thirdly, based on the already created SIGNAL_NODE a new object is created via Object.create nodewhich in essence will be the signal itself (or its instance). We assign it our initial signal value initialValue and create a function getterin which the function call occurs producerAccessed(node) and the value of the signal is returned. Next in the field [SIGNAL] functions getter'a assign the signal instance itself node (yes, JS can do it anyway, if anyone doubted it) and return getter.

Signal operation algorithm (Producer and Consumer)

Producers and Consumers

The internal implementation of signals is defined in terms of two abstractions, producers and consumers. Producers represent values ​​that can deliver change notifications, such as various kinds of signals. Consumers represent a reactive context that can depend on a number of producers. Both producers and consumers define a node object that implements the interface ReactiveNode and models participation in a reactive graph. Any ReactiveNode can act as a producer, consumer, or both at the same time, interacting with the corresponding subset of the API. For example, WritableSignals implement ReactiveNodebut only work with the producer's API, since WritableSignals do not consume other signal values, and computed in turn consume other signals to create new reactive values.

Dependency graph

ReactiveNode's are interconnected by a dependency graph. This dependency graph is bidirectional, but there are differences in how dependencies are tracked in each direction.

Consumers always keep track of the producers they depend on. Producers only track dependencies on consumers that are considered “live”. A Consumer is “alive” when:

  1. It sets the property consumerIsAlwaysLive your ReactiveNode to value true;

  2. He is also a producer, on whom the “living” consumer depends.

Relationship between Producer and Consumer

Relationship between Producer and Consumer

In this sense, “liveness” is a transitive property of consumers. All effects are “live” consumers by default, but computed ones are not. However, if any computed value is located inside an effect, the computed will be treated as a “live” consumer.

Liveness and memory management

The concept of liveness allows you to track dependencies from producer to consumer without the risk of memory leaks, for example:

    const counter = signal(1);
    let double = computed(() => counter() * 2);
    console.log(double()); // 2
    double = null;

If the dependency graph had a hard link from counter to double, then double would persist even if the user deleted the link to the original counter signal. But since double is not a “live” consumer, the graph does not contain a reference from counter to double, and double can be garbage collected when the user deletes it.

Relationship between counter and double

Relationship between counter and double

“Non-living” consumers and polling

The consequence of not tracking from counter to double is that when the counter changes:

    counter.set(2);

No notification can propagate in the graph from counter to double to tell computed that it needs to discard its stored value (2) and recalculate (4). Instead, when double() readit polls its producers (which are tracked in the graph) and checks to see if any of them have reported a change since the last time double was evaluated. If not, double can safely use its stored value.

With a “live” consumer, everything is different. If effect is created:

    effect(() => console.log(double()));

Then double becomes a “live” consumer, since it is a dependency of a “live” producer (effect), and the graph will have hard links counter->double->effect. However, there is no risk of memory leaks, since effect directly references double anyway, and the effect cannot simply be removed and must be manually destroyed (which will cause the double to no longer be a “living” consumer). That is, there is no way for a reference from a producer to a “living” consumer to exist in the graph without the consumer also referencing a producer outside the graph.

Algorithm in action

Let's go back to the code signal definition and follow the path of signal creation to the end. From method createSignal We have received a function that returns the value of the signal and contains its instance.

Next, we get the instance itself from the function, and if there is a comparison function options?.equalreplace it inside the signal itself, redefining defaultEquals.

    ...
    const node = signalFn[SIGNAL];
    if (options?.equal) {
        node.equal = options.equal;
    }
    ...

Then we set the necessary WritableSignal methods set, update and asReadonly, and in dev mode we will override the method toStringwhich will return a string with the value of the signal. The output is the same signal, or rather the function signalFn with all the necessary methods, containing a method for comparing signal values ​​and, when called, returning the value itself.

    ...
    signalFn.set = (newValue: T) => signalSetFn(node, newValue);
    signalFn.update = (updateFn: (value: T) => T) => signalUpdateFn(node, updateFn);
    signalFn.asReadonly = signalAsReadonlyFn.bind(signalFn as any) as () => Signal<T>;
    if (ngDevMode) {
        signalFn.toString = () => `[Signal: ${signalFn()}]`;
    }
    return signalFn as WritableSignal<T>;

Now, to understand how the algorithm works from the inside, you need to look at the implementation graph:

    type Version = number & {__brand: 'Version'};

    export interface ReactiveNode {
        version: Version;
        lastCleanEpoch: Version;
        dirty: boolean;
        producerNode: ReactiveNode[] | undefined;
        producerLastReadVersion: Version[] | undefined;
        producerIndexOfThis: number[] | undefined;
        nextProducerIndex: number;
        liveConsumerNode: ReactiveNode[] | undefined;
        liveConsumerIndexOfThis: number[] | undefined;
        consumerAllowSignalWrites: boolean;
        readonly consumerIsAlwaysLive: boolean;
        producerMustRecompute(node: unknown): boolean;
        producerRecomputeValue(node: unknown): void;
        consumerMarkedDirty(node: unknown): void;
        consumerOnSignalRead(node: unknown): void;
    }

    export const REACTIVE_NODE: ReactiveNode = {
        version: 0 as Version,
        lastCleanEpoch: 0 as Version,
        dirty: false,
        producerNode: undefined,
        producerLastReadVersion: undefined,
        producerIndexOfThis: undefined,
        nextProducerIndex: 0,
        liveConsumerNode: undefined,
        liveConsumerIndexOfThis: undefined,
        consumerAllowSignalWrites: false,
        consumerIsAlwaysLive: false,
        producerMustRecompute: () => false,
        producerRecomputeValue: () => {},
        consumerMarkedDirty: () => {},
        consumerOnSignalRead: () => {},
    };

Yes, yes, this is exactly what, in addition to public methods (set, update, asReadonly), contains node signal instance.
Now let's try to see what happens when we set the signal value via setnamely what happens inside signalSetFn:

    export function signalSetFn<T>(node: SignalNode<T>, newValue: T) {
        if (!producerUpdatesAllowed()) {
            throwInvalidWriteToSignalError();
        }

        if (!node.equal(node.value, newValue)) {
            node.value = newValue;
            signalValueChanged(node);
        }
    }

    /**
     * Whether this `ReactiveNode` in its producer capacity is currently allowed to initiate updates,
     * based on the current consumer context.
     */
    export function producerUpdatesAllowed(): boolean {
        return activeConsumer?.consumerAllowSignalWrites !== false;
    }

Before function signalSetFn will set a new signal value if it differs from the current one and start the change propagation process, the function producerUpdatesAllowed checks if this is currently allowed ReactiveNode (the current consumer) as a producer, initiate updates based on the current consumer context. If not, it will throw the appropriate exception.

    function signalValueChanged<T>(node: SignalNode<T>): void {
        node.version++;
        producerIncrementEpoch();
        producerNotifyConsumers(node);
        postSignalSetFn?.();
    }

    /**
     * Increment the global epoch counter.
     *
     * Called by source producers (that is, not computeds) whenever their values change.
     */
    export function producerIncrementEpoch(): void {
        epoch++;
    }

    /**
    * Propagate a dirty notification to live consumers of this producer.
    */
    export function producerNotifyConsumers(node: ReactiveNode): void {
        if (node.liveConsumerNode === undefined) {
            return;
        }

        // Prevent signal reads when we're updating the graph
        const prev = inNotificationPhase;
        inNotificationPhase = true;
        try {
            for (const consumer of node.liveConsumerNode) {
                if (!consumer.dirty) {
                    consumerMarkDirty(consumer);
                }
            }
        } finally {
            inNotificationPhase = prev;
        }
    }

    export function consumerMarkDirty(node: ReactiveNode): void {
        node.dirty = true;
        producerNotifyConsumers(node);
        node.consumerMarkedDirty?.(node);
    }

Otherwise, the mechanism for propagating changes in the signal value will start:

  1. producerIncrementEpoch will increase the global counter epoch – every time the data source changes, the counter will be updated;

  2. producerNotifyConsumers will notify all “live” consumers that their data source has changed. If the signal does not have “live” consumers (liveConsumerNode), the function terminates execution. Otherwise, for each consumer it is checked whether it is in a “dirty” state;

  3. And if not, the function is called consumerMarkDirty. This function marks the consumer as “dirty”, which means that its data is out of date and needs to be updated. After this it is called again producerNotifyConsumers for further notification to consumers.

    Flag inNotificationPhase allows you to prevent the signal from being read again at the time of notification to consumers, after which it restores the previous state (prev).

Let's summarize or Push/Pull Algorithm

Angular Signals guarantees crash-free execution by separating graph updates ReactiveNode into two phases.

The first phase executes immediately when the producer's value changes. This change notification is propagated through the graph, notifying live consumers that depend on this producer about a potential update. Some of these consumers may be derived values ​​and thus also be producers. They invalidate their cached values ​​and continue to propagate notification of the change to their own live consumers, and so on. The notification eventually reaches the effects, which schedule their execution again.

It is important to note that during this first phase, no side effects are performed, and no intermediate or derived values ​​are recalculated—only cached data is invalidated. This allows the change notification to reach all affected nodes in the graph without the risk of observing intermediate or incorrect states.

Once this change propagation is completed (synchronously), the second phase can begin. Here the signal values ​​can be read by the application or framework, which triggers the recalculation of all necessary derived values ​​that were previously invalidated. This process is called a “push/pull” algorithm: “dirtiness” (incorrect data) is actively passed along the graph when the original signal changes, but recalculation is performed lazily – only when values ​​are requested by reading their signals. You can read more about how the algorithm works here.

The considered implementation of signals in Angular showed that the concept of reactive values, although complex at first glance, is quite easy to understand. We have detailed the key functions and mechanisms that manage signal state changes, dependency tracking, and consumer notification. One of the advantages of this implementation is automatic reactivity management without the explicit use of subscriptions and third-party libraries.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *