DEV Community

Cover image for A journey through Angular Effects: How does it know to react?
Peter van der Wal
Peter van der Wal

Posted on

A journey through Angular Effects: How does it know to react?

Using signals in Angular has been really enjoyable. The use of its APIs and methods like computed and effect make life simple. However, I never quite understood how the effect method actually worked. So, inspired by youtuber Joshua Morony, I took a deep dive into the source code for the first time to find an answer.

How does Angular know to run the effect when the signal changes?

// 
effect(() => {
  const mySignalValue = mySignal();
})
Enter fullscreen mode Exit fullscreen mode

TL;DR

  • Each reactive function (computed, effect) is actually a factory that generates a node.
  • Angular has a global activeConsumer node variable, which is set prior to running an effect
  • When accessing the signal from inside an effect, that effect is set as a consumer of that signal
  • When signal.set() is called, it notifies its consumers by marking them dirty.

The journey

After downloading the source code from Angular's github, I dove into the create effect function, found in the angular/core package.

Discovery 1: Two types of effects

The first recognizable code I saw was the following:

 if (ngDevMode && !options?.injector) {
  assertInInjectionContext(effect);
}
Enter fullscreen mode Exit fullscreen mode

This explained where the error comes from when you write effect outside of a constructor. Why its needed followed soon after:

let node: EffectNode;

const viewContext = injector.get(ViewContext, null, {optional: true});
if (viewContext !== null) {
  // This effect was created in the context of a view, and will be associated with the view.
  node = createViewEffect(viewContext.view, notifier, effectFn);
} else {
  // This effect was created outside the context of a view, and will be scheduled independently.
  node = createRootEffect(effectFn, injector.get(EffectScheduler), notifier);
}
Enter fullscreen mode Exit fullscreen mode

There are two types of effects! One to associate with a view, another for independent effects. In this post I will focus only on the view effect.

Associating an effect with the view means it syncs with the lifecycle of a component. At this point, I guessed that the effect could run when changes of the component were detected. Inside the createViewEffect we actually see some of this happen:

export function createViewEffect(
  view: LView,
  notifier: ChangeDetectionScheduler,
  fn: (onCleanup: EffectCleanupRegisterFn) => void,
): ViewEffectNode {
  const node = Object.create(VIEW_EFFECT_NODE) as ViewEffectNode;
  node.view = view;
  ....
  view[EFFECTS].add(node);
  node.consumerMarkedDirty(node);
  return node;
}
Enter fullscreen mode Exit fullscreen mode

A effect node is create, and added to the EFFECTS property of the LogicalView. It then marks itself as dirty.

In the change detection code file we find this line, after some hooks already have been executed: (OnInit, OnChanges, DoCheck)

runEffectsInView(lView);

export function runEffectsInView(view: LView): void {
   ....,
  for (const effect of view[EFFECTS]) {
      if (!effect.dirty) {
        continue;
      }
      effect.run();
  }

Enter fullscreen mode Exit fullscreen mode

Discovery 2: An effect is a ReactiveNode object

In the previous example the result of the effect was put in a EffectNode type:

export interface BaseEffectNode extends ReactiveNode {
  fn: () => void;
  destroy(): void;
  cleanup(): void;
  run(): void;
}


export interface ReactiveNode {
 ....,
 dirty: boolean
}
Enter fullscreen mode Exit fullscreen mode

This told me that effect is actualy a factory function: It simply creates an object, that kept the function passed as a parameter in the object as the property: fn. There was also a signature for the run function from the previous discovery. Which did what it said: run the function and reset the dirty flag.

Lets summarize what was discovered so far:

  • Effects created in a constructor of a component run the method inside at least once.
  • The first run is after ngOnInit, hence input signals are available to this effect.
  • Once run, the node is no longer dirty.

But none of that explained how a signal inside of an effect can trigger the effect to run again. Somewhere, the effect node should be marked as dirty.

Discovery 3: Signals have a consumers property to notify

Conceptually, a signal should tell its consumers that it has changed. Following this trail of thought, I looked at the signal's source code.

When you call signal.set(someValue), it executes the following method:

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

Aha! As expected, the signalValueChanged method notifies the consumers.

Let's look inside:

export function producerNotifyConsumers(node: ReactiveNode): void {
  if (node.consumers === undefined) {
    return;
  }
  ....
  try {
    for (
      let link: ReactiveLink | undefined = node.consumers;
      link !== undefined;
      link = link.nextConsumer
    ) {
      const consumer = link.consumer;
      if (!consumer.dirty) {
        consumerMarkDirty(consumer);
      }
    }
  } 
  ....
}
Enter fullscreen mode Exit fullscreen mode

The method simple loops over the consumers of the signal and marks them dirty.

At this point, all we needed to know was how does an effect become a consumer of a signal. The answer is quite simple, you access them.

Discovery 4: Angular holds a global active node called activeConsumer

Lets go back to the run code of the effect. I missed something there before. When the effect.run() actually gets called the following code is run:

export function runEffect(node: BaseEffectNode) {
  node.dirty = false;
  if (node.version > 0 && !consumerPollProducersForChange(node)) {
    return;
  }
  node.version++;
  const prevNode = consumerBeforeComputation(node);
  try {
    node.cleanup();
    node.fn();
  } finally {
    consumerAfterComputation(node, prevNode);
  }
}

export function consumerBeforeComputation(node: ReactiveNode | null): ReactiveNode | null {
  if (node) resetConsumerBeforeComputation(node);

  return setActiveConsumer(node);
}
Enter fullscreen mode Exit fullscreen mode

This setActiveConsumer sets a global variable to highlight what node is running the code, before running the code.

So running effect(() => mySignal()) actually sets the activeConsumer to effect, before accessing the signal.

Why this important, we will find in the signal getter code:

export function signalGetFn<T>(node: SignalNode<T>): T {
  producerAccessed(node);
  return node.value;
}

export function producerAccessed(node){
  ...,
  const newLink = {
    producer: node,
    consumer: activeConsumer,
    // instead of eagerly destroying the previous link, we delay until we've finished recomputing
    // the producers list, so that we can destroy all of the old links at once.
    nextProducer: nextProducerLink,
    prevConsumer: prevConsumerLink,
    lastReadVersion: node.version,
    nextConsumer: undefined,
  };
  producerAddLiveConsumer(node, newLink);
  ...,
}
Enter fullscreen mode Exit fullscreen mode

Once a signal is accessed, it first verifies if the activeConsumer already has a link with the signal as a consumer. If not it adds it.

Now we have all the missing pieces and we understand how an effect is triggered

Summary

We have learned that when creating an effect, the effect is set as a consumer when a signal's value is read. Angular can do this by using a global variable which it sets before running the code. The signal's get function will use this reference during execution to set the effect node as its consumer, enabling it to notify changes.

Diving into the angular's sourcecode gave me more confidence by truly grasping some of its concepts. A pragmatic approach is needed, as you will not understand every detail right away and can get lost easily.

AI Disclaimer

This post was written entirely by human hands. AI (ChatGPT) was used briefly to get unstuck while going through the source code of Angular.

Top comments (0)