21 min read

Signals in Angular: deep dive for busy developers

Learn the inner workings of the implementation of signals in Angular to avoid confusion when relying on signals based architecture

Building complex user interfaces is a difficult task. In modern web applications, UI state is rarely comprised of simple standalone values. It’s rather a complicted computed state that depends on a complex hierarchy of other values or computed states. There’s a lot of work involved in managing that state: developers must store, compute, invalidate and sync those values.

Over the years a variety of frameworks and primitives have been introduced into web development to simplify this task. A central theme for most of them is reactive programming, which offers infrastructure for managing application state, allowing developers to concentrate on business logic rather than repetitive tasks of state management.

The most recent addition is signals, a “reactive” primitive which represents a dynamically changing value and can notify interested consumers when the value changes. Those in turn can run recomputes or various side-effect, e.g. creating/destroying components, running network requests, updating DOM, etc.

We can find different implementations of signals in different frameworks. There’s even an effort now to standardize signals:

… this effort focuses on aligning the JavaScript ecosystem. Several framework authors are collaborating here on a common model which could back their reactivity core. The current draft is based on design input from the authors/maintainers of Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz, and more…

The implementation of signals in Angular very much resembles the implementation provided as part of the proposal, so I might make cross-references betwee the two in this article.

Original article and more deep dives can be found on Angular Love blog.

Signals as primitives

A signal represents a cell of data which may change over time. Signals may be either “state” (just a value which is set manually) or “computed” (think of a formula based on other signals).

Computed signals function by automatically tracking which other signals are read during their evaluation. When a computed signal is read, it checks whether any of its previously recorded dependencies have changed, and re-evaluates itself if so.

For example, here we have a state signal counter and a computed signalisEven. We set the initial value for counter to 0 and later change it to 1. You can see that the computed signal isEven reacts to the changes by producing two different values before and after the update of the counter signal:

import { computed, signal } from '@angular/core'; 
 
// state/writable signal 
const counter = signal(0); 
 
// computed signal 
const isEven = computed(() => (counter() & 1) == 0); 
 
counter() // 0 
isEven() // true 
 
counter.set(1) 
 
counter() // 1 
isEven() // false

Notice also that in the example above the isEven signal does not explicitly subscribe to the source counter signal. Instead, it simply calls the source signal using counter() within its computed function. This is sufficient to link the two signals. As a result, whenever the counter source signal updates with a new value, the derived signal automatically gets updated as well.

Both state and computed signals are considered producers of values. Producers represent signals that produce values and can deliver change notifications.

A state signal changes (produces) its value when the value is updated through the API call, while the computed signal generates new value automatically when the dependencies used in the callback change.

Computed signals can also be consumers, because may they depend on some number of producers (consume). In other reactive implementations, e.g. Rx, consumers are also known as sinks.

When a producer signal’s value is changed, the values of dependent consumers, e.g. computed signals, are not immediately updated. When a computed signal is read, it checks if any of its previously recorded dependencies have changed and re-evaluates itself if necessary.

This makes computed signals lazy, or pull-based, meaning they are only evaluated when accessed, even if the underlying state changed earlier. In our example above, the computed signal value is only evaluated when we call isEven(), although the update to the underlying dependency counter happened earlier, when we executed counter.set().

Besides regural writable and computed signals, there’s also a concept of watchers (effects). In contrast to the pull based evalulation of computed signals, changing a producer signal will immediately notify a wacher, synchronously calling watcher’s notification callback, effectively “pushing” the notification. Frameworks wrap watchers into effects that are exposed to users. Effects delay notification of user code through scheduling.

Unlike Promises, everything in signals runs synchronously:

  • Setting a signal to a new value is synchronous, and this is immediately reflected when reading any computed signal which depends on it afterwards. There is no built-in batching of this mutation.
  • Reading computed signals is synchronous — their value is always available.
  • Watchers are notified synchronously, but effects that wrap those watchers may opt to batch and delay notification through scheduling.

Implementation details

Internally, the implementation of signals defines a number of concepts that I want to explain in this article: reactive context, dependency graph and effects (watchers). Let’s start with the reactive context.

To discuss reactive context, think of a stack frame (execution frame) that defines the environment within which JavaScript code is evaluated and executed. Particulalry, it defines which objects (variables) are available to the function. You can say that the availability of those objects defines a context. For example, a function that is run in the web worker context, doesn’t have access to the document global object.

The reactive context defines an active consumer object, that depend on producers, and is available to their accessor function whenever their value is read. For example, we have a consumer isEvent here, that depends on the counter producer (consumes its value). That’s dependency is defined by accessing the value of counter inside the computed callback:

isEvent = computed(() => (counter() & 1) === 0)

When the computed callback will run, it will automatically execute the accessor function of the counter signal to get its value. We can say that in that case the counter signal is being executed in the reactive context of the isEvent consumer. So, a producer is being executed in the reactive context if there’s an active consumer that depends on this producer’s value.

To implement this mechanism of reactive context, every time when a consumer’s value is accessed, but before it’s recomputed (before the computed callback is run), we can set this consumer as the active consumer. This can be done by simply assigning that consumer object to the global variable and keep it there while the callback is being executed. This global variable will be availabe to all producers queiried during the executi on of computed callback, and it will define the reactive context for all producers that this consumer depends on.

That’s exactly what Angular is doing. When the computed callback is executed, it will first set the current node as active consumer in producerRecomputeValue:

function producerRecomputeValue(node: ComputedNode<unknown>): void { 
  ... 
  const prevConsumer = consumerBeforeComputation(node); 
  let newValue: unknown; 
  try { 
    newValue = node.computation(); 
  } catch (err) {...} finally {...} 
 
function consumerBeforeComputation(node: ReactiveNode | null) { 
  node && (node.nextProducerIndex = 0); 
  return setActiveConsumer(node); 
}

Angular gets there from the producerUpdateValueVersion inside createComputed factory function:

function createComputed<T>(computation: () => T): ComputedGetter<T> { 
  ... 
  const computed = () => { 
    producerUpdateValueVersion(node); 
    ... 
  }; 
} 
 
function producerUpdateValueVersion(node: ReactiveNode): void { 
  ... 
  node.producerRecomputeValue(node); 
  ... 
}

This callstack also clearly demonstrates this implementation:

Because of that, while the computed’s callback is being executed, every producer that’s queried during the time of that consumer being active, will know that they are executed in the reactive context. All producers executed in the reactive context of a particular consumer are added as the dependencies of the consumer. This makes up a reactive graph.

Most of the pre-existing functionality in Angular is executed in a non-reactive context. You can observe that by simply searching for the usage of setActiveConsumer with null value:

For example, before running lifecyle hooks, Angular clears the reactive context:

/** 
 * Executes a single lifecycle hook, making sure that: 
 * - it is called in the non-reactive context; 
 * - profiling data are registered. 
 */ 
function callHookInternal(directive: any, hook: () => void) { 
  profiler(ProfilerEvent.LifecycleHookStart, directive, hook); 
  const prevConsumer = setActiveConsumer(null); 
  try { 
    hook.call(directive); 
  } finally { 
    setActiveConsumer(prevConsumer); 
    profiler(ProfilerEvent.LifecycleHookEnd, directive, hook); 
  } 
}

Angular template functions (component views) and effects are run in the reactive contexts.

Reactive graph

The reactive graph is built through depenencies between consumers and producers. Reactive context implementation through value accessors make it possible for signal dependencies to be tracked automatically and implicitly. Users do not need to declare arrays of dependencies, nor does the set of dependencies of a particular context need to remain static across executions.

When a producer is executed, it adds itself to the depedencies of the current active consumer (the consumer defining the current reactive context). This happens inside the producerAccessed function:

export function producerAccessed(node: ReactiveNode): void { 
  ... 
  // This producer is the `idx`th dependency of `activeConsumer`. 
    const idx = activeConsumer.nextProducerIndex++; 
    if (activeConsumer.producerNode[idx] !== node) { 
      // We're a new dependency of the consumer (at `idx`). 
      activeConsumer.producerNode[idx] = node; 
      // If the active consumer is live, then add it as a live consumer. If not, then use 0 as a 
      // placeholder value. 
      activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer) 
        ? producerAddLiveConsumer(node, activeConsumer, idx) 
        : 0; 
    }

Both producers and consumers participate in the reactive graph. This dependency graph is bidirectional, but there are differences in which dependencies are tracked in each direction.

Producers are tracked as dependencies of a consumer trough the producerNode property, creating edges from consumers to producers:

interface ConsumerNode extends ReactiveNode { 
  producerNode: NonNullable<ReactiveNode['producerNode']>; 
  producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>; 
  producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;

Certain consumers are also tracked as “live” consumers and create edges in the other direction, from producer to consumer. These edges are used to propagate change notifications when a producer’s value is updated:

interface ProducerNode extends ReactiveNode { 
  liveConsumerNode: NonNullable<ReactiveNode['liveConsumerNode']>; 
  liveConsumerIndexOfThis: NonNullable<ReactiveNode['liveConsumerIndexOfThis']>; 
}

Consumers always keep track of the producers they depend on. Producers only track dependencies from consumers which are considered “live”. A consumer is “live” when it has consumerIsAlwaysLive property set to true, or is a producer which is depended upon by a live consumer.

In Angular, two types of nodes are defined as live consumers:

  • watch nodes (used in effects)
  • reactive LView nodes (used in change detection)

Here’s their definitions:

const WATCH_NODE: Partial<WatchNode> = /* @__PURE__ */ (() => { 
  return { 
    ...REACTIVE_NODE, 
    consumerIsAlwaysLive: true, 
    consumerAllowSignalWrites: false, 
    consumerMarkedDirty: (node: WatchNode) => { 
      if (node.schedule !== null) { 
        node.schedule(node.ref); 
      } 
    }, 
    hasRun: false, 
    cleanupFn: NOOP_CLEANUP_FN, 
  }; 
})(); 
 
const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = { 
  ...REACTIVE_NODE, 
  consumerIsAlwaysLive: true, 
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => { 
    markAncestorsForTraversal(node.lView!); 
  }, 
  consumerOnSignalRead(this: ReactiveLViewConsumer): void { 
    this.lView![REACTIVE_TEMPLATE_CONSUMER] = this; 
  }, 
};

In some contexts, computed signals may become “live” consumers, for example, when used in an effect callback.

The following code setup:

import { ChangeDetectorRef, Component, computed, effect, signal } from '@angular/core'; 
import { SIGNAL } from '@angular/core/primitives/signals'; 
 
@Component({ 
  standalone: true, 
  selector: 'app-root', 
  template: 'Angular Love', 
  styles: [] 
}) 
export class AppComponent { 
  constructor(private cdRef: ChangeDetectorRef) { 
    const a = signal(0); 
 
    const b = computed(() => a() + 'b'); 
    const c = computed(() => a() + 'c'); 
    const d = computed(() => b() + c() + 'd'); 
 
    const nodes = [a[SIGNAL], b[SIGNAL], c[SIGNAL], d[SIGNAL]] as any[]; 
 
    d(); 
 
    const A = 0, B = 1, C = 2, D = 3; 
 
    const depBToA = nodes[B].producerNode[0] === nodes[A]; 
    const depCToA = nodes[C].producerNode[0] === nodes[A]; 
    const depDToB = nodes[D].producerNode[0] === nodes[B]; 
    const depDToC = nodes[D].producerNode[1] === nodes[C]; 
 
    console.log(depBToA, depCToA, depDToB, depDToC); 
 
    const e = effect(() => b()) as any; 
 
    // need to wait for change detection to notify the effect 
    setTimeout(() => { 
      // effect depends on B 
      const depEToB = e.watcher[SIGNAL].producerNode[0] === nodes[B]; 
 
      // live consumers link from producer A to B, 
      // and from B to E, because E (effect) is a live consumer 
      const depLiveAToB = nodes[A].liveConsumerNode[0] === nodes[B]; 
      const depLiveBToE = nodes[B].liveConsumerNode[0] === e.watcher[SIGNAL]; 
 
      console.log(depLiveAToB, depLiveBToE, depEToB); 
    }); 
  } 
}

will produce the following graph:

The implementation of a reactive context through the active consumer enables dynamic dependency tracking. When a certain consumer is set as active, the producers being evaluated are defined dynamically via the sequence of those producers calls. The dependency list might be re-arranged for an ActiveConsumer every time a producer is accessed in this consumer’s reactive context.

To implement that, dependencies of a consumer are tracked in the producerNode array:

interface ConsumerNode extends ReactiveNode { 
  producerNode: NonNullable<ReactiveNode['producerNode']>; 
  producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>; 
  producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;

When the computation for a particular consumer is rerun, a pointer (index) producerIndexOfThis into that array is initialized to the index 0, and each dependency read is compared against the dependency from the previous run at the pointer's current location. If there's a mismatch, then the dependencies have changed since the last run, and the old dependency can be dropped and replaced with the new one. At the end of the run, any remaining unmatched dependencies can be dropped.

This means that if you have a dependency needed on only one branch, and the previous calculation took the other branch, then a change to that temporarily unused value will not cause the computed signal to be recalculated, even when pulled. This results in the possibility of different set of signals being accessed from one execution to the next.

For example, this computed signal dynamic reads either dataA or dataB depending on the value of the useA signal:

const dynamic = computed(() => useA() ? dataA() : dataB());

At any given point, it will have a dependency set of either [useA, dataA] or [useA, dataB], and it can never depend on dataA and dataB at the same time.

This code, similar to this test case in Angular, clearly demonstrates that:

import { computed, signal } from '@angular/core'; 
import { SIGNAL} from '@angular/core/primitives/signals'; 
 
const states = Array.from('abcdefgh').map((s) => signal(s)); 
const sources = signal(states); 
 
const vComputed = computed(() => { 
  let str = ''; 
  for (const state of sources()) str += state(); 
  return str; 
}); 
 
const n = vComputed[SIGNAL] as any; 
expectEqual(vComputed(), 'abcdefgh'); 
expectEqualArrayElements(n.producerNode.slice(1), states.map(s => s[SIGNAL])); 
 
sources.set(states.slice(0, 5)); 
expectEqual(vComputed(), 'abcde'); 
expectEqualArrayElements(n.producerNode.slice(1), states.slice(0, 5).map(s => s[SIGNAL])); 
 
sources.set(states.slice(3)); 
expectEqual(vComputed(), 'defgh'); 
expectEqualArrayElements(n.producerNode.slice(1), states.slice(3).map(s => s[SIGNAL])); 
 
function expectEqual(v1, v2): any { 
  if (v1 !== v2) throw new Error(`Expected ${v1} to equal ${v2}`); 
} 
function expectEqualArrayElements(v1, v2): any { 
  if (v1.length !== v2.length) throw new Error(`Expected ${v1} to equal ${v2}`); 
  for (let i = 0; i < v1.length; i++) { 
    if (v1[i] !== v2[i]) throw new Error(`Expected ${v1} to equal ${v2}`); 
  } 
}

As you can see, there’s no one single starting vertix for the graph. Since each consumer keeps a list of dependency producers, which in turn may have dependencies, e.g. computed signal, so you can say that each consumer at the time of being accessed is a root vertex of a graph.

Two phase updates

Earlier push-based models for reactivity faced an issue of redundant computation: if an update to a state signal causes the computed signal to eagerly run, ultimately this may push an update to the UI. But this write to the UI may be premature, if there was going to be another change to the originating state signal before the next frame.

For example for the graph like this, this problem involves inadvertently evaluating A -> B -> D, and C, and then re-evaluating D because C has changed. Re-evaluating D twice is inefficient and can lead to noticeable glitches for the user.

This is known as the diamond problem.

Sometimes, inaccurate intermediate values were even shown to end-users due to such glitches. Signals avoid this dynamic by being pull-based (lazy), rather than push-based: At the time the framework schedules the rendering of the UI, it will pull the appropriate updates, avoiding wasted work both in computation as well as in writing to the DOM.

Consider this example:

const a = signal(0); 
 
const b = computed(() => a() + 'b'); 
const c = computed(() => a() + 'c'); 
const d = computed(() => b() + c() + 'd'); 
 
// run the computed callback to set up dependencies 
d(); 
 
// update the signal at the top of the graph 
setTimeout(() => a.set(1), 2000);

Once a is updated there’s no propogation happening. Only the value and the version of the node are updated:

function signalSetFn(node, newValue) { 
  ... 
  if (!node.equal(node.value, newValue)) { 
    node.value = newValue; 
    signalValueChanged(node); 
  } 
} 
 
function signalValueChanged(node) { 
  node.version++; 
  ... 
}

When we later access the value for d(), signals implementation polls dependencies upwards of d through consumerPollProducersForChange to determine if the recompute is necessary.

For effecient processing, all reactive nodes record the version of the dependency node. To determine the change, it’s enough to simply compare the saved version of the producer node with the actual version on the node:

interface ConsumerNode extends ReactiveNode { 
  ... 
  producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>; 
} 
 
function consumerPollProducersForChange(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; 
    }

If those differ, there’s a been a change to the producer and the implementain will run recompute of the computed callback through producerRecomputeValue:

export function producerUpdateValueVersion(node: ReactiveNode): void { 
  ... 
 
  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; 
    node.lastCleanEpoch = epoch; 
    return; 
  } 
 
  node.producerRecomputeValue(node); 
 
  // After recomputing the value, we're no longer dirty. 
  node.dirty = false; 
  node.lastCleanEpoch = epoch; 
}

Which will repeat the process for the dependencies of C. In this way it will reach node A, which at this point will result in the evaluation of the branch D->C->A. But since D also depends on the B producer, it will re-evaluate that one before computing D. In this way, there’s no problem of double computation for D.

Sometimes, though, you may have a need to eagerly notify certain consumers. As you may have guessed, those are known as “live” consumers. In this case, the change notification is propagated through the graph as soon as the producer value is updated, notifying live consumers which depend on the producer.

Some of these consumers may be derived values and thus also producers, which invalidate their cached values and then continue the propagation of the change notification to their own live consumers, and so on. Ultimately this notification reaches effects, which schedule themselves for re-execution.

Crucially, during this phase, no side effects are run, and no recomputation of intermediate or derived values is performed, only invalidation of cached values. This allows the change notification to reach all affected nodes in the graph without the possibility of observing intermediate or glitchy states.

If needed, once this change propagation has completed (synchronously), this stage can be followed by the lazy evaluation we looked at above.

To see this notification phase in action, let’s add a live consumer, e.g. an watcher, to our setup. When a is updated, the update is propagated to dependent live consumers:

import { computed, signal } from '@angular/core'; 
import { createWatch } from '@angular/core/primitives/signals'; 
 
const a = signal(0); 
const b = computed(() => a() + 'b'); 
const c = computed(() => a() + 'c'); 
const d = computed(() => b() + c() + 'd'); 
 
setTimeout(() => a.set(1), 3000); 
 
// watcher will setup a dependency on `d` 
const watcher = createWatch( 
  () => console.log(d()), 
  () => setTimeout(watcher.run, 1000), 
  false 
); 
 
watcher.notify();

As soon as we update the value for a.set(1) we can see the notification of live consumers in action:

Nodes b and c are live consumers of the node a, hence when running update for a, Angular will go over node.liveConsumerNode and notify those nodes about the change.

But as mentioned earlier, nothing is really happening here. The node is simply marked as dirty and propagates the notification to its live consumers through producerNotifyConsumers:

function consumerMarkDirty(node) { 
  node.dirty = true; 
  producerNotifyConsumers(node); 
  node.consumerMarkedDirty?.(node); 
}

All this goes way down to the watcher (effect) that depends on d. As opposed to regular reactive nodes, the watch node implements scheduling in its consumerMarkedDirty method:

const WATCH_NODE: Partial<WatchNode> = (() => { 
  return { 
    ...REACTIVE_NODE, 
    consumerIsAlwaysLive: true, 
    consumerAllowSignalWrites: false, 
    consumerMarkedDirty: (node: WatchNode) => { 
      if (node.schedule !== null) { 
        node.schedule(node.ref); 
      } 
    }, 
    hasRun: false, 
    cleanupFn: NOOP_CLEANUP_FN, 
  }; 
})();

And here the notification phase and graph traversal stops.

This two-staged process is sometimes referred to as the “push/pull” algorithm: “dirtiness” is eagerly pushed through the graph when a source signal is changed, but recalculation is performed lazily, only when values are pulled by reading their signals.

Change detection

To integrate signals based notifications into the change detection process, Angular relies on the mechanism of live consumers. Component templates are compiled into template expressions (JS code) and are executed in the reactive context of that component’s view. In such contexts, executing a signal will return the value, but also register the signal as a dependency of the the component’s view.

Because template expressions are live consumers, Angular will create a link from the producer to the template expression node. As soon as the producer’s value is updated, that producer will notify the template node immediately and synchronously. Upon notification, Angular will mark the component and all its ancestors for check.

As you may already know from my other articles, each component’s template internally is represented as LView object. Here’s how it looks for the component:

@Component({...}) 
export class AppComponent { 
  value = signal(0); 
}

when compiled it looks like regular JS function AppComponent_Template that is executed during the change detection for this component:

this.ɵcmp = defineComponent({ 
  type: AppComponent, 
  ... 
  template: function AppComponent_Template(rf, ctx) { 
    if (rf & 1) { 
      ɵɵtext(0); 
    } 
    if (rf & 2) { 
      ɵɵtextInterpolate1("", ctx.value(), "\n"); 
    } 
  }, 
});

When Angular added signals to its change detection implementation, it wrapped all component views (template function) in a ReactiveLViewConsumer node:

export interface ReactiveLViewConsumer extends ReactiveNode { 
  lView: LView | null; 
}

The interface is implementated by REACTIVE_LVIEW_CONSUMER_NODE node:

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = { 
  ...REACTIVE_NODE, 
  consumerIsAlwaysLive: true, 
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => { 
    markAncestorsForTraversal(node.lView!); 
  }, 
  consumerOnSignalRead(this: ReactiveLViewConsumer): void { 
    this.lView![REACTIVE_TEMPLATE_CONSUMER] = this; 
  }, 
};

You can think of this process as each view getting its own ReactiveLViewConsumer consumer node defines the reactive context for all signals accessed inside the template function.

In our case, whenever the template function runs as part of change detection, it will execute the ctx.value() producer in the context of the template function node, which is being an ActiveConsumer:

This will result in the template expression node (consumer) being added as a live dependency to the producer value():

This dependency ensures that once the value of the producer counter changes, it will immediately notify the consumer node (template expression).

Live consumers implement consumerMarkDirty method that’s called synchronously by the producer when its value changes:

/** 
 * Propagate a dirty notification to live consumers of this producer. 
 */ 
function producerNotifyConsumers(node: ReactiveNode): void { 
  ... 
  try { 
    for (const consumer of node.liveConsumerNode) { 
      if (!consumer.dirty) { 
        consumerMarkDirty(consumer); 
      } 
    } 
  } finally { 
    inNotificationPhase = prev; 
  } 
} 
 
function consumerMarkDirty(node: ReactiveNode): void { 
  node.dirty = true; 
  producerNotifyConsumers(node); 
  node.consumerMarkedDirty?.(node); 
}

Inside consumerMarkedDirty the template expression node will mark ancestors for refresh using markAncestorsForTraversal in the manner similar to how markForCheck() did it before:

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = { 
  ... 
  consumerMarkedDirty: (node: ReactiveLViewConsumer) => { 
    markAncestorsForTraversal(node.lView!); 
  }, 
}; 
 
function markAncestorsForTraversal(lView: LView) { 
  let parent = getLViewParent(lView); 
  while (parent !== null) { 
    ... 
    parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh; 
    parent = getLViewParent(parent); 
  } 
}

The last question is when does Angular set the current LView consumer node as an ActiveConsumer? This all happens inside the refreshView function that you may already know from my previous articles.

This function runs change detection for on each LView and runs common change detection operations: executing a template function, executing hooks, refreshing queries and setting host bindings. Basically an entire piece of code to handle reactivity has been added before Angular runs all those operations.

Here’s how it looks:

function refreshView<T>(tView, lView, templateFn, context) { 
  ... 
 
  // Start component reactive context 
  enterView(lView); 
  let returnConsumerToPool = true; 
  let prevConsumer: ReactiveNode | null = null; 
  let currentConsumer: ReactiveLViewConsumer | null = null; 
  if (!isInCheckNoChangesPass) { 
    if (viewShouldHaveReactiveConsumer(tView)) { 
      currentConsumer = getOrBorrowReactiveLViewConsumer(lView); 
      prevConsumer = consumerBeforeComputation(currentConsumer); 
    } else {... } 
 
    ... 
 
    try { 
      ... 
      if (templateFn !== null) { 
        executeTemplate(tView, lView, templateFn, RenderFlags.Update, context); 
      } 
  }

Since this code is executed before Angular runs the component’s template function in executeTemplate code, when the accessor functions of signals used in a component template are executed, there’s alredy a reactive context setup.

Hybrid change detection

In v18 Angular switched to hybrid change detection mechanism which enables zone-less change propagation when a signal changes its value. The part that implements this behavior is added to the markAncestorsForTraversal function call:

export function markAncestorsForTraversal(lView: LView) { 
  lView[ENVIRONMENT].changeDetectionScheduler?.notify( 
      NotificationSource.MarkAncestorsForTraversal 
  ); 
   
  let parent = getLViewParent(lView); 
  while (parent !== null) { ... } 
}

The changeDetectionScheduler service implements the notify method, that will schedule a change detection run aka tick either through micro/macro task scheduler:

notify(source: NotificationSource): void { 
    ... 
    const scheduleCallback = this.useMicrotaskScheduler 
      ? scheduleCallbackWithMicrotask 
      : scheduleCallbackWithRafRace; 
 
    this.pendingRenderTaskId = this.taskService.add(); 
    if (this.zoneIsDefined) { 
      Zone.root.run(() => { 
        this.cancelScheduledCallback = scheduleCallback(() => { 
          this.tick(this.shouldRefreshViews); 
        }); 
      }); 
    } else { 
      this.cancelScheduledCallback = scheduleCallback(() => { 
        this.tick(this.shouldRefreshViews); 
      }); 
    } 
  }

Effects and watchers

An effect is a specialized tool that is meant to perform side-effectful operations based on the state of the application. Effects are live consumers defined with a callback that’s executed in a reactive context. Signal dependencies of this function are captured, and effect is notified whenever any of its dependencies produce a new value.

Effects are rarely needed in most application code, but may be useful in specific circumstances. Here are some usage examples suggested in Angular docs:

  • Logging data or keeping it in sync with window.localStorage
  • Adding custom DOM behavior that can’t be expressed with template syntax, e.g. performing custom rendering to a <canvas> element

Angular doesn’t use effects in the change detection mechanism to trigger component’s UI update. As explained in the section on change detection, for this functionaliy it relies on the mechanism of live consumers.

While the signal algorithm is standardized, the details of how effects should behave is not defined and will vary across frameworks. This is due to the subtle nature of effect scheduling, which often integrates with framework rendering cycles and other high-level, framework-specific states or strategies that JavaScript cannot access.

However, the signals proposal defines a set of primitives, namely watch API, that framework authors can use to create their own effects. The Watcher interface is used to watch a reactive function and receive notifications when the dependencies of that function change.

In Angular, effect is a wrapper over watcher. First let’s explore how watchers work and we will see how they are used to build effect primitive.

First, we will import the watcher from Angular primitives and use it to implement a notification mechanism:

import { createWatch } from '@angular/core/primitives/signals'; 
 
const counter = signal(0); 
 
const watcher = createWatch( 
  // run the user provided callback and set up tracking 
  // this will be executed 2 times 
  // 1st after `watcher.notify()` and 2nd time after `this.counter.set(1)` 
  () => counter(), 
  // this is called by the `notify` method  
  // or by the consumer itself through through consumerMarkDirty method, 
  // schedules the user provided callback to run in 1000ms 
  () => setTimeout(watcher.run, 1000), 
  false 
); 
 
// mark the watcher as dirty (stale) to force the user provided callback  
// to run and set up tracking for the `counter` signal 
// `notify` method will call `consumerMarkDirty` under the hood 
watcher.notify(); 
 
// when the value changes, consumerMarkDirty is executed 
// which schedules the user provided callback to run 
setTimeout(() => this.counter.set(1), 3000);

When we run watcher.notify(), Angular synchronously calls consumerMarkDirty method on a watcher node. However, a user defined notification callback is not executed immedialy upon the notification. Instead, it’s scheduled to run through watcher.run some time in the future. The watch will simply call this scheduling operation when it receives the “markDirty” notification.

Here you can see in action:

When we run this.counter.set(1) the same chain of calls leads to the scheduling of the user provided callback.

To build the effect() function, Angular wraps the watcher inside the EffectHandle class:

export function effect(effectFn,options): EffectRef { 
  const handle = new EffectHandle(); 
  ... 
  return handle; 
} 
 
class EffectHandle implements EffectRef, SchedulableEffect { 
  unregisterOnDestroy: (() => void) | undefined; 
  readonly watcher: Watch; 
 
  constructor(...) { 
    this.watcher = createWatch( 
      (onCleanup) => this.runEffect(onCleanup), 
      () => this.schedule(), 
      allowSignalWrites, 
    ); 
    this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy()); 
  }

You can see that EffectHandle class is where the watcher is setup. For our example above where we used watchers before, using the effect function will significantly simplify the setup:

import { Component, effect, signal } from '@angular/core'; 
 
@Component({...}) 
export class AppComponent { 
  counter = null; 
 
  constructor() { 
    this.counter = signal(0); 
 
    // this will be executed 2 times 
    effect(() => this.counter()); 
 
    setTimeout(() => this.counter.set(1), 3000); 
  } 
}

When we use the effect function directly, we only pass one callback. This is the user defined callback that sets up dependencies and is scheduled for the run by Angular when dependencies are updated.

The current scheduler used in Angular effects is ZoneAwareEffectScheduler, which runs updates as part of the microtask queue after the change detection cycle:

export class ZoneAwareEffectScheduler implements EffectScheduler { 
  private queuedEffectCount = 0; 
  private queues = new Map<Zone | null, Set<SchedulableEffect>>(); 
  private readonly pendingTasks = inject(PendingTasks); 
  private taskId: number | null = null; 
 
  scheduleEffect(handle: SchedulableEffect): void { 
      this.enqueue(handle); 
      if (this.taskId === null) { 
        const taskId = (this.taskId = this.pendingTasks.add()); 
        queueMicrotask(() => { 
          this.flush(); 
          this.pendingTasks.remove(taskId); 
          this.taskId = null; 
        }); 
      } 
    }

There’s one interesting quirk that Angular must implement to “intialize” the effect. As we saw in the implementation with watcher, we need to kick off tracking by manually calling watcher.notify() once. Angular needs to do this as well, and does so as part of the first run of change detection.

Here’s how it’s implemented.

When you execute the effect function inside the component’s injection context, Angular will add the notification callback to the component’s view object LView[EFFECTS_TO_SCHEDULE] :

export function effect( 
  effectFn: (onCleanup: EffectCleanupRegisterFn) => void, 
  options?: CreateEffectOptions, 
): EffectRef { 
  ... 
  const handle = new EffectHandle(); 
 
  // Effects need to be marked dirty manually to trigger their initial run. The timing of this 
  // marking matters, because the effects may read signals that track component inputs, which are 
  // only available after those components have had their first update pass. 
  // ... 
  const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef<unknown> | null; 
  if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) { 
    // This effect is either not running in a view injector, or the view has already 
    // undergone its first change detection pass, which is necessary for any required inputs to be 
    // set. 
    handle.watcher.notify(); 
  } else { 
    // Delay the initialization of the effect until the view is fully initialized. 
    (cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify); 
  } 
 
  return handle; 
}

The notification functions added in this way will be executed once during the first change detection run for this component’s view inside refreshView function:

export function refreshView<T>(tView,lView,templateFn,context) { 
   ... 
   
   // Schedule any effects that are waiting on the update pass of this view. 
    if (lView[EFFECTS_TO_SCHEDULE]) { 
      for (const notifyEffect of lView[EFFECTS_TO_SCHEDULE]) { 
        notifyEffect(); 
      } 
 
      // Once they've been run, we can drop the array. 
      lView[EFFECTS_TO_SCHEDULE] = null; 
    } 
}

Calling notifyEffect will trigger consumerMarkDirty notification callback of the underlying watcher, which in turn will schedule the effect (user provided callback) to run using existing scheduler (after the change detection):

And that’s the whole story :)