Effect: what is it and how does it work?

Vlad Sharikov
AngularWave

--

Let’s continue discovering reactivity systems in general and one of its implementations by Google: Angular Signals. The reactivity system, in general, consists of a few primitives. These primitives are a signal, a derivation (a computed signal) and a reaction. The effect function is a tool that allows you to describe a reaction when other signals change.

Little remark. In my article cycle, I am focusing on Angular Signals in depth. There are other implementations like MobX, SolidJS, and Vue. They share core ideas, philosophy and architecture with Angular Signals. There is an excellent article by Ryan Carniato. If you want to understand the general concept of fine-grained reactivity and see the big picture, I highly encourage you to read this article. In his words:

This article is not going to focus on the "how". I will attempt to provide the most gentle introduction into the Fine-grained reactivity the approach used by libraries like MobX, Vue, Svelte, Knockout, and Solid.

Also, if you want to understand reactivity systems and Angular Signals better, you should not skip the initial RFC for the Angular Signals. There is the primary RFC and also four sub-discussions. All of them contain a huge amount of information, and they also have tons of comments by the community. For now, let’s proceed to the effect decomposition.

First, let’s check what we are discussing right now to be on the same page. The effect function is needed to react to one or more signal changes. You use it like that:

savedDraft  = signal('some test');
currentTime = signal(Date.now());
status = computed(() => `Draft length: ${this.savedDraft().length}, updated at: ${this.currentTime}`);

ngOnInit() {
setInterval(() => this.currentTime.update(v => v + 1), 1000);
}

saveDraft() {
this.savedDraft.set(this.getCurrentDraft());
}

The effect requires an injection context, and the easiest way to provide it is to use it within a component, directive, or service. You can also provide an object with injector as second argument to the effect function if you want to use it outside of a constructor. Also, you have themanualCleanup option & the ability to save the reference to a signal to manually destroy it if you want to limit the signal’s lifespan. You can read more on this in the official docs. The following is guaranteed in case of effects:

  • effects always execute asynchronously during the change detection process;
  • effects will execute at least once;
  • effects will execute in response to their dependencies changes at some point in the future;
  • effects will execute a minimal number of times: if an effect depends on multiple signals and several of them change at once, only one effect execution will be scheduled.

Let’s check what is happening under the hood of the effect function and how all of the above is achieved. Today, we are going to discuss it based on the following example:

@Component({
selector: 'app-root',
template: `
<button (click)="inc()">Inc</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent {
counter = signal(0);

inc() {
this.counter.set(this.counter() + 1);
}

constructor() {
effect(() => {
console.log(`current value is ${this.counter()}`)
})
}
}

We are going to check:

  • How and when does the initial run of the effect happen?
  • Why do effects happen asynchronously, and why does it happen only once after the underlying signals change?
  • Why are they connected to the change detection cycle?
  • How are effects scheduled to re-run when one or more underlying signals change?

Let’s break it down!

What happens when the effect is created?

If you put a debugger statement to the effect function and open the app with the devtools opened, you will hit that breakpoint immediately. Let’s check what is there in the stack. You can see something like that:

the effect function of the MyComponent <-- you are here
the runEffect method of the EffectHandle
the createWatch function (which creates a special WATCH_NODE)
the flushQueue & flush methods of the ZoneAwareQueueingScheduler
the flushTask method of the ZoneAwareMicrotaskScheduler
a few zone.js calls
the queueMicrotask async <-- that is a marker of the async code
a few more zone.js calls
the scheduleEffect of the ZoneAwareMicrotaskScheduler
the schedule method of the EffectHandle
the consumerMarkedDirty of the created WATCH_NODE
the consumerMarkDirty function
the notify method of the create WATCH_NODE
the refreshView function <-- this is a part of the Angular Change Detection cycle

That is a pretty long stack there. Let’s try to put it all together. There are a few entities in that process: the end component MyComponent, EffectHandle, WATCH_NODE, ZoneAwareQueueingScheduler, ZoneAwareMicrotaskScheduler, queueMicrotask call, and the refreshView function that is part of the Angular CD cycle.

The key takeaway is the queueMicrotask call in the middle of the process. As I mentioned, one guaranteed thing is that effects are triggered asynchronously. Looking at the call stack above and that queueMicrotask function call, you clearly see why it is asynchronous. Now, let’s check how the effect is created. Let’s check the effect function:

export function effect(
effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
options?: CreateEffectOptions): EffectRef {
!options?.injector && assertInInjectionContext(effect);
const injector = options?.injector ?? inject(Injector);
const errorHandler = injector.get(ErrorHandler, null, {optional: true});
const destroyRef = options?.manualCleanup !== true ? injector.get(DestroyRef) : null;

const handle = new EffectHandle(
injector.get(APP_EFFECT_SCHEDULER), effectFn,
(typeof Zone === 'undefined') ? null : Zone.current, destroyRef, errorHandler,
options?.allowSignalWrites ?? false)

const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef<unknown>| null;
if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) {
handle.watcher.notify();
} else {
(cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);
}
return handle;
}

This function is an entry point. It gathers together all of the parameters, creates internal structures based on them, and starts the process. The main thing there is creating an EffectHandle (which returns a handle) and adding the handle.watcher.notify function to the cdr._lView[EFFECTS_TO_SCHEDULE] array (right now, we are decomposing the example when the effect is a part of the component’s constructor). That is interesting. Let’s search the EFFECTS_TO_SCHEDULE construction in the Angular’s code. You will find that this array is used in the refreshView function:

export function refreshView<T>(...) ... {
// ...

// 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;
}

// ...
}

The refreshView function plays a crucial part in Angular’s change detection. That shows that an effect is scheduled somewhere during the change detection process. In the end, the notifyEffect call will lead to the execution of the effect in the component. I will explain what happens in the notify function a bit later.

Why is it necessary to schedule an effect run from there? Your effect can use other signals. Those signals can be very different. Some can be set in the ngOnChanges hook, which is a part of change detection. In the future, Angular will have signal inputs & can have queries, such as ContentChild or ViewChild, which are calculated during the change detection, too. The effect must be fired once after all of those happened so it will have the most actual state and the moment when it is kicked off guarantees that.

Small remark: you can see the following in the effect function:

if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) {
handle.watcher.notify();
} else {
(cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);
}

There is a part when the notify method will be invoked right away, without adding it to the lView[EFFECTS_TO_SCHEDULE]. That is needed if you create an effect somewhere outside of components' life-cycle hooks. For example, in a reaction to some user event like click or in setTimeout.

Now, let’s consider the new EffectHandle part. The first argument is the injector.get(APP_EFFECT_SCHEDULER) which means that Angular will get the APP_EFFECT_SCHEDULER through the dependency injection mechanism. Let’s check what is it:

export const APP_EFFECT_SCHEDULER = new InjectionToken('', {
providedIn: 'root',
factory: () => inject(EffectScheduler),
});

/**
* A scheduler which manages the execution of effects.
*/
export abstract class EffectScheduler {
/**
* Schedule the given effect to be executed at a later time.
*
* It is an error to attempt to execute any effects synchronously during a scheduling operation.
*/
abstract scheduleEffect(e: SchedulableEffect): void;

/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: EffectScheduler,
providedIn: 'root',
factory: () => new ZoneAwareMicrotaskScheduler(),
});
}

Here we got to the next part: the ZoneAwareMicrotaskScheduler. At the start, I mentioned that some methods of this object are called, and here is how it comes to the game.

Now, let’s check the EffectHandle. The EffectHandle gets an instance of the scheduler through the constructor parameters (it is the ZoneAwareMicrotaskScheduler from the above). Also, the constructor creates the watcher by calling the createWatch function. It passes its methods into the createWatch call. To sum up, that class creates the watcher & connects it with the scheduler.

Now, let’s proceed to the createWatch function. Its code:

export function createWatch(
fn: (onCleanup: WatchCleanupRegisterFn) => void, schedule: (watch: Watch) => void,
allowSignalWrites: boolean): Watch {
const node: WatchNode = Object.create(WATCH_NODE);
if (allowSignalWrites) {
node.consumerAllowSignalWrites = true;
}

node.fn = fn;
node.schedule = schedule;

const registerOnCleanup = (cleanupFn: WatchCleanupFn) => {
node.cleanupFn = cleanupFn;
};

function isWatchNodeDestroyed(node: WatchNode) {
return node.fn === null && node.schedule === null;
}

function destroyWatchNode(node: WatchNode) {
if (!isWatchNodeDestroyed(node)) {
consumerDestroy(node); // disconnect watcher from the reactive graph
node.cleanupFn();

// nullify references to the integration functions to mark node as destroyed
node.fn = null;
node.schedule = null;
node.cleanupFn = NOOP_CLEANUP_FN;
}
}

const run = () => {
if (node.fn === null) {
// trying to run a destroyed watch is noop
return;
}

if (isInNotificationPhase()) {
throw new Error(`Schedulers cannot synchronously execute watches while scheduling.`);
}

node.dirty = false;
if (node.hasRun && !consumerPollProducersForChange(node)) {
return;
}
node.hasRun = true;

const prevConsumer = consumerBeforeComputation(node);
try {
node.cleanupFn();
node.cleanupFn = NOOP_CLEANUP_FN;
node.fn(registerOnCleanup);
} finally {
consumerAfterComputation(node, prevConsumer);
}
};

node.ref = {
notify: () => consumerMarkDirty(node),
run,
cleanup: () => node.cleanupFn(),
destroy: () => destroyWatchNode(node),
[SIGNAL]: node,
};

return node.ref;
}

This function, finally, creates the reactive node in the Object.create(WATCH_NODE) expression. Here is the WATCH_NODE itself:

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,
};
})();

The previous articles show that the reactivity system builds a dependency graph. In the case of effects, that happens here in that specific line. The key takeaway now is that when you create an effect the reactivity node is created under the hood and the dependency graph starts to get built. Also, pay attention to the consumerMarkedDirty part. You can see the node.schedule call there. It is what was passed as the second argument to the createWatch function. The node has that method because of the node.schedule = schedule; expression in the createWatch.

Besides that, this function has a lot of code to maintain the node's state. Note the run function, it is crucial to get the effect invoked, and I will get back to it in the next section.

At the end of the createWatch function, a few methods are assigned to the node.ref , and the node.ref is returned:

node.ref = {
notify: () => consumerMarkDirty(node),
run,
cleanup: () => node.cleanupFn(),
destroy: () => destroyWatchNode(node),
[SIGNAL]: node,
};

Check the notify method. That method was put to the lView[EFFECTS_TO_SCHEDULE] and that method will be called right after the Angular’s change detection. That gets us to the next part.

How are effects triggered initially?

From the start of the previous section, you know that in the effect function, there is a code that runs or schedules the notify call. Calling the notify means calling the consumerMarkDirty function. Let’s check this function:

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

The node is not a producer, so let’s skip the producerNotifyConsumers. Let’s check the node.consumerMarkedDirty?.(node) part. You can see that method in the previous section. It calls the node.schedule(node.ref). That method is a part of the EffectHandle:

class EffectHandle {
// ...
schedule() {
this.scheduler.scheduleEffect(this);
}
// ...
}

As you remember, the scheduler is the ZoneAwareMicrotaskScheduler. Let's check it now:

export class ZoneAwareMicrotaskScheduler implements EffectScheduler {
private hasQueuedFlush = false;
private delegate = new ZoneAwareQueueingScheduler();
private flushTask = () => {
this.delegate.flush();
this.hasQueuedFlush = false;
};

scheduleEffect(handle: SchedulableEffect): void {
this.delegate.scheduleEffect(handle);

if (!this.hasQueuedFlush) {
queueMicrotask(this.flushTask);
this.hasQueuedFlush = true;
}
}
}

The scheduleEffect method is going to be called. Also, pay attention to a delegate that was created along with that class. That property is an instance of the ZoneAwareQueueingScheduler. This class has the scheduleEffect and flush methods. The scheduleEffect one is adding the effect handle to the queue. The flush is emptying those handles from the queue and calling the run method on each effect handle. Let's check it, too:

export class ZoneAwareQueueingScheduler implements EffectScheduler, FlushableEffectRunner {
private queuedEffectCount = 0;
private queues = new Map<Zone|null, Set<SchedulableEffect>>();

scheduleEffect(handle: SchedulableEffect): void {
const zone = handle.creationZone as Zone | null;
if (!this.queues.has(zone)) {
this.queues.set(zone, new Set());
}

const queue = this.queues.get(zone)!;
if (queue.has(handle)) {
return;
}
this.queuedEffectCount++;
queue.add(handle);
}

flush(): void {
while (this.queuedEffectCount > 0) {
for (const [zone, queue] of this.queues) {
// `zone` here must be defined.
if (zone === null) {
this.flushQueue(queue);
} else {
zone.run(() => this.flushQueue(queue));
}
}
}
}

private flushQueue(queue: Set<SchedulableEffect>): void {
for (const handle of queue) {
queue.delete(handle);
this.queuedEffectCount--;

// TODO: what happens if this throws an error?
handle.run();
}
}

/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: ZoneAwareQueueingScheduler,
providedIn: 'root',
factory: () => new ZoneAwareQueueingScheduler(),
});
}

You can see the handle.run call in the private flushQueue method. That run is actually a run method of the EffectHandle that we discussed before:

class EffectHandle {
// ...
run() {
this.watcher.run();
}
// ...
}

And the this.watcher.run method is a run function in the createWatch. So, let's sum it up:

  1. The notify of the node was called
  2. Which called the consumerMarkedDirty method was called
  3. It called the node.schedule function
  4. That led to the schedule method of the EffectHandle
  5. Which called the scheduleEffect of the ZoneAwareMicrotaskScheduler
  6. In which the effect handle was added to some queue in the scheduleEffect method of the ZoneAwareQueueingScheduler
  7. After that, the queue was flushed asynchronously using the queueMicrotask function in the flush method of the ZoneAwareQueueingScheduler
  8. Which means that run method of each EffectHandle in the queue was called

So, that is how we get back to the run function from the createWatch call. Let’s break it down. Parts of that function should be familiar to you if you read previous articles (If not, please do, because I explain the basics of signals in Angular there; I gave links to those articles at the very start of that post). You can see the node.hasRun && !consumerPollProducersForChange(node) expression as a part of the condition with early return. If that condition returns false, the effect is not going to happen. As before, in the first run case, the node.hasRun part is false, which is why the second part of the expression is not called. It will be needed later. After that, the hasRun state is changed, and in the next block of the code, the effect is going to happen. Let’s consider this block:

const prevConsumer = consumerBeforeComputation(node);
try {
node.cleanupFn();
node.cleanupFn = NOOP_CLEANUP_FN;
node.fn(registerOnCleanup);
} finally {
consumerAfterComputation(node, prevConsumer);
}

That is something very similar to how the computed signals work. In the computed case, the same thing happened while computing the value in the producerRecomputeValue method. Let’s rewind a bit. That is how the reactivity system builds the dependencies between nodes automatically:

  1. Set the current node as the active consumer;
  2. Run the computed/effect function in which one or more signals will be accessed;
  3. For every signal while they are accessed, if there is an active consumer, the link will be built between two nodes.

In the end, the node.fn is called, which means executing the function that you registered as the effect. Finally, so far, I explained:

  • What happens when the effect is being created?
  • How exactly is the effect triggered after the components' change detection?
  • How does that happen asynchronously?

Why do effects re-run on signal changes?

In the previous article, while we were discovering how different parts of the reactivity graph interact, I mentioned that, in some cases, there are “live” consumers. I told you that we will get back to it in the near future. The future came. Let’s check what it is and how does it work. Remember our effect:

effect(() => {
console.log(`current value is ${this.counter()}`)
})

Also, remember that in the latest section, we grasped that under the hood, there is a code that will call this expression in the end. That is a very similar process compared to the computed signals. The effect call created a reactive node. After that, when the effect function is called, our effect reactive node will be the current active consumer. It is set up in the consumerBeforeComputation function. In the effect, there is an expression containing another signal this.counter(). This signal is being accessed while the effect is running. That will create a new node and a link between nodes. Let’s remember that. Here is the code of creation of the simple writable signal:

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;
}

There is a getter function. When the effect runs, and the this.counter() expression is executed, that getter function is invoked. You can see the producerAccessed function in it:

export function producerAccessed(node: ReactiveNode): void {
// .....

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;
}
activeConsumer.producerLastReadVersion[idx] = node.version;
}

// ...

function consumerIsLive(node: ReactiveNode): boolean {
return node.consumerIsAlwaysLive || (node?.liveConsumerNode?.length ?? 0) > 0;
}

I also added the consumerIsLive function. I covered this code in the previous articles, so for now, let’s focus on the live consumers part. In the current case, the activeConsumer is a reactive node which was created by invoking the effect function. This node has the consumerIsAlwaysLive property which is true. Almost at the end of that function, there is a part that I skipped in the previous article, let’s check it now:

activeConsumer.producerIndexOfThis[idx] =
consumerIsLive(activeConsumer)
? producerAddLiveConsumer(node, activeConsumer, idx)
: 0;

The consumerIsLive function returns true in the case of effects, because the consumerIsAlwaysLive property of the activeConsumer is true Let’s check what is happening in the producerAddLiveConsumer function:

function producerAddLiveConsumer(
node: ReactiveNode, consumer: ReactiveNode, indexOfThis: number): number {
assertProducerNode(node);
assertConsumerNode(node);
if (node.liveConsumerNode.length === 0) {
// When going from 0 to 1 live consumers, we become a live consumer to our producers.
for (let i = 0; i < node.producerNode.length; i++) {
node.producerIndexOfThis[i] = producerAddLiveConsumer(node.producerNode[i], node, i);
}
}
node.liveConsumerIndexOfThis.push(indexOfThis);
return node.liveConsumerNode.push(consumer) - 1;
}

At the end of that function, you can see that the consumer, which is the effect reactive node, is added to the liveConsumerNode array of the counter reactivity node.

That is very important for automatic re-schedule of the effect when the underlying signal is changed. Let’s get back to the example: the component renders a button, and when the user clicks the button, the counter is increased: this.counter.set(this.counter() + 1). Let’s briefly check what happens. When you call the set method, the signalSetFn is called. It compares the new value & the current value, and if values are not equal, the signalValueChanged is going to be called. That function calls the producerNotifyConsumers function. Here is what it is doing:

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;
}
}

Long story short, it goes through an array of liveConsumerNode and calls the consumerMarkDirty function on each consumer. Right now, everything is pretty much the same. The consumerMarkDirty runs the node.consumerMarkedDirty?.(node) and it schedules the effect absolutely the same way as that happened in the previous section. Here is how the effects are rescheduled when their dependencies change.

More on the asynchronous nature of effects

Consider the following example:

export class MyComponent {
first = signal(0);
second = signal(0);

inc() {
this.first.set(this.first() + 1);
this.second.set(this.second() + 1);
}

constructor() {
effect(() => {
console.log(`current counter is ${this.first()}, another is: ${this.second()}`)
})
}
}

Now, the effect depends on two signals. When the inc method is called, both signals will change their value. From the Introduction to Angular Signals, you know the properties of reactivity systems, and you know they are glitch-free. That means they don’t expose inconsistent states to the user. The inc method is invoked, and all expressions are executed synchronously, one by one. During the first set call, the effect is scheduled for future asynchronous execution through the queueMicrotask. The inc method body continues its synchronous execution, and the second expression is executed synchronously, too. Later, the effect will be invoked while the browser flushes the microtasks queue after completing the synchronous part. That guarantees that all synchronous changes and updates will be finished before that moment. At that point, all effects dependencies will be up to date, and there will be no glitches.

So, the asynchronous nature of effects allows Angular’s reactivity system to be glitch-free. If the effect runs synchronously, there will be a glitch because the user will have two logs: one log after the first signal changes and one more log after the second signal changes.

In that article, we checked the effects:

  • How are they created?
  • How are they scheduled initially?
  • Why did it turn out that they are asynchronous, and why is it needed?
  • How are they rescheduled when their dependencies change?

That is it for today about the effects. I want to thank the guys from AngularWave Roman and Alex for their help with reviewing that article. I am going to cover other topics, so please follow me on Twitter to receive new releases and updates.

--

--