Angular’s Signal Revolution: Effortless Change Detection Explained — Unveiling the Inner Workings
Angular’s signals bring a host of benefits to the table, one of which is their ability to seamlessly integrate with templates and “automate” change detection. This automation means that a component configured with the OnPush
change detection strategy will be rechecked during the next change detection cycle, sparing developers from manually injecting ChangeDetectorRef
and invoking markForCheck
. To illustrate this advantage, consider the following example:
@Component({
selector: 'app-foo',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ num }}`,
})
export class FooComponent {
num = 1;
private cdr = inject(ChangeDetectorRef);
ngOnInit() {
setTimeout(() => {
this.num = 2;
this.cdr.markForCheck();
}, 3000);
}
}
Here, we explicitly inject ChangeDetectorRef
and invoke markForCheck
after updating the component's state. The new way with signals:
@Component({
selector: 'app-foo',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `{{ num() }}`,
})
export class FooComponent {
num = signal(0)
ngOnInit() {
setTimeout(() => this.num.set(1), 3000);
}
}
In this improved version, we use a signal
, and remarkably, the view still updates without needing to manually call markForCheck
. In this article, we will delve into the inner workings of this feature, shedding light on how signals streamline change detection in Angular applications.
At a higher level of abstraction, you can conceptualize the current template as the consumer
and every signal as the producer
. When a signal undergoes a value change, it promptly notifies the template, which subsequently triggers a call to markForCheck
.
Angular employs the ReactiveLViewConsumer
class, an extension of the ReactiveNode
class, to facilitate its reactive architecture. In this architecture, each reactive node corresponds to a node within the reactive graph. These nodes can play various roles, acting as producers of reactive values, consumers of other reactive values, or fulfilling both roles simultaneously.
In our specific context, the ReactiveLViewConsumer
serves as a consumer for the signals
we utilize within our templates.
When Angular executes the component template
function, its initial step involves obtaining a reference to the reactive view consumer:
export function executeTemplate<T>(
tView: TView, lView: LView<T>,
templateFn: ComponentTemplate<T>,
rf: RenderFlags,
context: T) {
const consumer = getReactiveLViewConsumer(lView, REACTIVE_TEMPLATE_CONSUMER);
...
}
The getReactiveLViewConsumer
function simply retrieves the current instance of the reactive view consumer from a designated slot in the LView
or create a new instance if it doesn’t exists.
Next, it proceeds to invoke the runInContext
function within the ReactiveLViewConsumer
class. This function is responsible for orchestrating the following tasks:
class ReactiveLViewConsumer {
runInContext(
fn: HostBindingsFunction<unknown> | ComponentTemplate<unknown>,
rf: RenderFlags,
ctx: unknown,
): void {
const prevConsumer = setActiveConsumer(this);
this.trackingVersion++;
try {
fn(rf, ctx);
} finally {
setActiveConsumer(prevConsumer);
}
}
}
It temporarily sets the current consumer to the current instance of ReactiveLViewConsumer
, ensuring that any reactive values accessed during the template execution function are attributed to this consumer.
In simpler terms, what this signifies is that our reactive view consumer has effectively noted and registered each signal
that is employed within the template. This happens when we access the signal
as you can see here:
class WritableSignalImpl<T> extends ReactiveNode {
// ...
signal(): T {
this.producerAccessed();
return this.value;
}
}
The producerAccessed
method is invoked, marking the current signal as a producer dependency for our reactive view consumer.
Moving forward, when the signal
undergoes a value update, it invokes the producerMayHaveChanged
method, which subsequently triggers the onConsumerDependencyMayHaveChanged
method for each consumer that has registered for this producer.
In our specific context, the ReactiveLViewConsumer
class overrides this method, and when called, it in turn initiates a markViewDirty
operation for the current view.
Lastly, the process concludes with the invocation of the commitLViewConsumerIfHasProducers
function. This function serves the purpose of preserving the current reactive view consumer instance, but only if there are producers within the template.
Follow me on Medium or Twitter to read more about Angular and JS!