A change detection, zone.js, zoneless, local change detection, and signals story š
Angular is a component-driven framework. And just like every other framework out there, it is supposed to show the data to the user and refresh the view when the data changes.
A UserCard component that shows user data in the template
Over time, we build more and more components and compose them together, and we may end up with a component tree like the one below. Component structure
But, how does Angular know when to refresh the view? How does it know when the data changes? How does it know when to run the change detection?
Change detection with synchronous code
Letās start with a simple example. We have a component with a property name
and a method changeName
. When we click the button, we call the changeName
method and change the name
property.
@Component({
template: `
<button (click)="changeName()">Change name</button>
<p>{{ name }}</p>
`
})
export class AppComponent {
name = 'John';
changeName() {
this.name = 'Jane';
}
}
When we click the button, the changeName
method is called, and because everything is wrapped by Angular, we can safely assume that after the name is changed, Angular can run some code to update the view (and everything will be in sync).
ā ļø Imaginary under the hood Angular code:
component.changeName();
// This code is run Angular will run change detection for the whole component tree because we may have updated some data in a service that is used by other components
angular.runChangeDetection();
This works fine! But, most of the time when we change the data, we donāt do it synchronously. We usually make an HTTP request, or have some timers, or wait for some other events to happen before updating the data. And thatās where the problems start.
Change detection with asynchronous code
Now, letās say that we want to change the name after 1 second. We can do that with the setTimeout
function.
@Component({
template: `
<button (click)="changeName()">Change name</button>
<p>{{ name }}</p>
`
})
export class AppComponent {
name = 'John';
changeName() {
setTimeout(() => {
this.name = 'Jane';
}, 1000);
}
}
When we click the button, the changeName
method is called, and the setTimeout
function is called. The setTimeout
function will wait for 1 second and then call the callback function. The callback function will change the name to Jane
.
And now, letās add the same imaginary under-the-hood Angular code as before.
ā ļø Imaginary under the hood Angular code:
component.changeName(); // uses setTimeout inside
// this code is run immediately after the changeName method is called
angular.runChangeDetection();
Because of the call stack
, the setTimeout
callback will be called after the angular.runChangeDetection
method. So, Angular ran the change detection but the name was not changed yet. And thatās why the view will not be updated. And thatās a broken application ā ļø. (In reality, itās not, because we have š)
Zone.js to the rescue
Zone.js has been around since the early days of Angular 2.0. It is a library that monkey patches the browser APIs and enables us to hook into the lifecycle of the browser events. What does that mean? It means that we can run our code before and after the browser events.
setTimeout(() => {
console.log('Hello world');
}, 1000);
The code above will print Hello world
after 1 second. But, what if we want to run some code before or after the setTimeout
callback? You know, for business reasons š. A framework called Angular may want to run some code before and after the setTimeout callback.
Zone.js
enables us to do that. We can create a zone (Angular does create a zone too) and hook into the setTimeout
callback.
const zone = Zone.current.fork({
onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
console.log('Before setTimeout');
delegate.invokeTask(target, task, applyThis, applyArgs);
console.log('After setTimeout');
}
});
To run our setTimeout
inside the zone, we need to use the zone.run()
method.
zone.run(() => {
setTimeout(() => {
console.log('Hello world');
}, 1000);
});
Now, when we run the code above, we will see the following output.
Before setTimeout
Hello world
After setTimeout
And thatās how zone.js works. It monkey patches the browser APIs and enables us to hook into the lifecycle of the browser events.
Zone.js + Angular
Angular loads zone.js
by default in every application and creates a zone called NgZone
. NgZone
includes an Observable called onMicrotaskEmpty
. This observable emits a value when there are no more microtasks in the queue. And thatās what Angular uses to know when all the asynchronous code is finished running and it can safely run the change detection.
NgZone wraps every angular application today
Letās look at the underlying code:
// ng_zone_scheduling.ts NgZoneChangeDetectionScheduler
this._onMicrotaskEmptySubscription = this.zone.onMicrotaskEmpty.subscribe({
next: () => this.zone.run(() => this.applicationRef.tick())
});
What we see in the code above is that Angular will call the applicationRef.tick()
method when the onMicrotaskEmpty
observable emits a value. Whatās this tick
method š¤? Do you remember the runChangeDetection
method from the imaginary under-the-hood Angular code? Well, the tick method is the runChangeDetection
method. It runs the change detection for the whole component tree synchronously.
But now, Angular knows that all the asynchronous code has finished running and it can safely run the change detection.
tick(): void {
// code removed for brevity
for (let view of this._views) {
// runs the change detection for a single component
view.detectChanges();
}
}
The tick
method will iterate over all the root views (most of the time we have only one root view/component which is AppComponent
) and run detectChanges
synchronously.
Component Dirty marking
One other thing Angular does is that it marks the component as dirty when it knows something inside the component changed.
These are the things that mark the component as dirty:
- Events (click, mouseover, etc.)
Every time we click a button with a listener in the template, Angular will wrap the callback function with a function called wrapListenerIn_markDirtyAndPreventDefault. And as we can see from the name of the function š , it will mark the component as dirty.
function wrapListener(): EventListener {
return function wrapListenerIn_markDirtyAndPreventDefault(e: any) {
// ... code removed for brevity
markViewDirty(startView); // mark the component as dirty
};
}
- Changed inputs
Also, while running the change detection, Angular will check if the input value of a component has changed (=== check). If it has changed, it will mark the component as dirty. Source code here.
setInput(name: string, value: unknown): void {
// Do not set the input if it is the same as the last value
if (Object.is(this.previousInputValues.get(name), value)) {
return;
}
// code removed for brevity
setInputsForProperty(lView[TVIEW], lView, dataValue, name, value);
markViewDirty(childComponentLView); // mark the component as dirty
}
- Output emissions
To listen to output emissions in Angular we register an event in the template. As we saw before, the callback fn will be wrapped and when the event is emitted, the component will be marked as dirty.
Letās see what this markViewDirty function does.
/**
* Marks current view and all ancestors dirty.
*/
export function markViewDirty(lView: LView): LView|null {
while (lView) {
lView[FLAGS] |= LViewFlags.Dirty;
const parent = getLViewParent(lView);
// Stop traversing up as soon as you find a root view that wasn't attached to any container
if (isRootView(lView) && !parent) {
return lView;
}
// continue otherwise
lView = parent!;
}
return null;
}
As we can read from the comment, the markViewDirty
function will mark the current view and all ancestors dirty. Letās see the image below to better understand what that means.
Dirty marking component and its ancestor up to the root
So, when we click the button, Angular will call our callback fn (changeName
) and because itās wrapped with the wrapListenerIn_markDirtyAndPreventDefault
function, it will mark the component as dirty.
As we said before, Angular uses zone.js and wraps our app with it.
NgZone wraps Angular apps
After dirty marking to the top, wrapListenerIn_markDirtyAndPreventDefault
fires and triggers zone.js
Event listeners notify zone.js
Because Angular is listening to the onMicrotaskEmpty
observable, and because the (click) registers an event listener, which zone has wrapped, zone will know that the event listener has finished running and it can emit a value to the onMicrotaskEmpty
observable.
onMicrotaskEmpty fires when thereās no microtask running anymore
onMicrotaskEmpty
tells Angular itās time to run the change detection.
Component binding refresh
The moment Angular runs the change detection it will check every component from top to bottom. It will go over all the components (dirty and non-dirty) and check their bindings. If the binding has changed, it will update the view.
But, why does Angular check all the components š¤? Why doesnāt it check only the dirty components š¤?
Well, because of the change detection strategy.
OnPush Change Detection
Angular has a change detection strategy called OnPush
. When we use this strategy, Angular will only run the change detection for a component that is marked as dirty.
First, letās change the change detection strategy to OnPush
.
@Component({
// ...
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCard {}
Letās look at the graphic below to better understand how the change detection works with the OnPush
strategy.
Letās do the same thing as before. Click a button in the component and change the name.
First, we will have the Dirty Marking phase.
Then, the event listener will notify zone.js.
Event notifies zone.js
When everything async has finished running, onMicrotaskEmpty
will fire.
Now, Angular will run the tick
method, and what it will do is traverse all components from top to bottom and check each component.
If the component is:
- OnPush + Non-Dirty -> Skip
- OnPush + Dirty -> Check bindings -> Refresh bindings -> Check children
As we can see, by using OnPush we can skip parts of the tree that we know havenāt had any changes.
OnPush + Observables + async pipe
When we work with Angular, observables have been our number one tool to manage data and state changes. To support observables, Angular provides the async
pipe. The async
pipe subscribes to an observable and returns the latest value. To let Angular know that the value has changed, it will call the markForCheck
method that comes from the ChangeDetectorRef
class (the componentās ChangeDetectorRef
).
@Pipe()
export class AsyncPipe implements OnDestroy, PipeTransform {
constructor(ref: ChangeDetectorRef) {}
transform<T>(obj: Observable<T>): T|null {
// code removed for brevity
}
private _updateLatestValue(async: any, value: Object): void {
// code removed for brevity
this._ref!.markForCheck(); // <-- marks component for check
}
}
Iāve written more about it here (create async pipe from scratch and understand how it works):
And what the markForCheck
method does is that it will just call the markViewDirty
function that we saw before.
// view_ref.ts
markForCheck(): void {
markViewDirty(this._cdRefInjectingView || this._lView);
}
So, the same as before, if we use observables with async pipe in the template it will act the same as if we used the (click) event. It will mark the component as dirty and Angular will run the change detection.
data$ | async pipe marks component as dirty
OnPush + Observables + Who is triggering zone.js?
If our data changes without our interaction (click, mouseover etc.) probably there is a setTimeout
or setInterval
or an HTTP call being made somewhere under the hood that triggers zone.js.
Hereās how we can easily break it š§Ø
@Component({
selector: 'todos',
standalone: true,
imports: [AsyncPipe, JsonPipe],
template: `{{ todos$ | async | json }}`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodosComponent {
private http = inject(HttpClient);
private ngZone = inject(NgZone);
todos$ = of([] as any[]);
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
setTimeout(() => {
// this will be updated, but there's nothing triggering zonejs
this.todos$ = this.getTodos();
});
});
}
getTodos() {
return this.http
.get<any>('https://jsonplaceholder.typicode.com/todos/1')
.pipe(shareReplay(1));
}
}
What we have done here is:
- In
ngOnInit
, we have usedngZone.runOutsideAngular()
an API that allows us to run things outside the Angular zone. - We use
setTimeout
(to skip the first task being run and also because Angular runs change detection at least once by default) and inside thesetTimeout
, we assign a value to the observable (yay we have a change). - Because
setTimeout
wonāt run inside the zone, also the API call will be made outside the zone because the code is run insiderunOutsideAngular
, there is nothing notifying zonejs that something changed. - Run this code in your app and see that only ā[]ā will be shown in the browser.
- Broken State š§Ø!
Not great š! But, one other thing that we start questioning is:
Why do we need to mark all the ancestors dirty?
The reason for this is simple, if we donāt mark all ancestors as dirty, we can get a broken state even faster. How?
Letās see the above example again, but now, mark only the component and its children as dirty.
So, we mark only the component that had the click and its children to be marked for check. The moment the tick
happens it will get to the parent component which is OnPush
, check that itās not dirty, and skip it.
Broken state if we donāt mark ancestors as dirty when using markForCheck
Thatās how we get to a broken state again š§Ø!
Why canāt we just run the change detection for the component that is marked as dirty?
We can do that using the detectChanges
method in the ChangeDetectorRef
class. But it has its drawbacks. Because that method runs change detection synchronously, it can cause performance issues. Because everything will be done in the same browser task, it may block the main thread and cause jank. Imagine detecting changes for a list of 100 items every 1 or 2 seconds. Thatās a lot of work for the browser.
markForCheck vs detectChanges (coalesced run vs sync runs)
When we use markForCheck
we just tell Angular that a component is dirty, and nothing else happens, so even if we call markForCheck
1000 times itās not going to be an issue. But, when we use detectChanges
, Angular will do actual work like checking bindings and updating the view if needed. And thatās why we should use markForCheck
instead of detectChanges
.
Canāt we schedule detectChanges in the next browser task?
We can, thatās what push pipe or rxLet directive from rx-angular does. It schedules the change detection in the next browser task. But, itās not a good idea to do that for every component. Because, if we have a list of 100 items, and we schedule the change detection for every item, we will have 100 browser tasks. And thatās not good for performance either.
Signals š¦
The frontend world is moving towards signals. Solid.js, Svelte, Vue, and Angular are creating their signal implementations. And thatās because signals are a better way to manage state and state changes.
Signals in Angular have brought a lot of DX benefits. We can easily create and derive state and also run side effects when the state changes using effects. We donāt have to subscribe to them, we donāt have to unsubscribe from them, and we donāt have to worry about memory leaks š§Æ.
We can just call them and they will return their current value.
const name = signal('John'); // create a signal with initial value
const upperCaseName = computed(() => name().toUpperCase()); // create a computed signal
effect(() => {
console.log(name() + ' ' + upperCaseName()); // run side effect when name or upperCaseName changes
});
setTimeout(() => {
name('Jane'); // change the name after 1 second
}, 1000);
// Output:
// John JOHN
// Jane JANE
We can also use signals in the template just like normal function calls.
@Component({
template: `
<button (click)="name.set('Jane')">Change name</button>
<p>{{ name() }}</p>
`
})
export class AppComponent {
name = signal('John');
}
If you ask if calling a function in the template is a good idea, I would say that itās a good idea if the function call is cheap, and calling a signal is cheap. Itās just a function call that returns a value (without computing anything).
Iāve also written about it here:
Signals and Change Detection
In v17 Angular change detection got an upgrade š!
Angular templates now understand signals as something more than function calls. Below is one of the PRs making this a reality.
feat(core): Mark components for check if they read a signal
Before we used the async
pipe, so it would call the markForCheck
method, and with signals, we just have to normally call them. Angular now will register an effect
(consumer) that will listen to this signal and mark the template for check every time the signal changes.
The first benefit is that we donāt need async pipe anymore š.
The second PR that improves change detection is this one:
fix(core): Ensure backwards-referenced transplanted views are refreshed
Which solved an issue not related to signals but to change detection itself (which I wonāt explain in detail).
By using the mechanism introduced by it, we got the 3rd PR which added Glo-cal change detection (Global + Local change detection) (a term coined by my friend @Matthieu Riegler)
perf(core): Update LView consumer to only mark component for check
So letās better understand glo-cal (local) change detection š
Local Change Detection (Targeted mode)
One of those PRs I linked above introduced two new flags in Angular.
Two new flags (RefreshView & HAS_CHILD_VIEWS_TO_REFRESH)
How do they work?
When the template effect runs, Angular will run a function called markViewForRefresh
which sets the current component flag to RefreshView
and then calls markAncestorsForTraversal
which will mark all the ancestors with HAS_CHILD_VIEWS_TO_REFRESH
.
/**
* Adds the `RefreshView` flag from the lView and updates HAS_CHILD_VIEWS_TO_REFRESH flag of
* parents.
*/
export function markViewForRefresh(lView: LView) {
if (lView[FLAGS] & LViewFlags.RefreshView) {
return;
}
lView[FLAGS] |= LViewFlags.RefreshView;
if (viewAttachedToChangeDetector(lView)) {
markAncestorsForTraversal(lView);
}
}
And hereās how it looks in the graph (updated tree structure to showcase more edge cases)š
So, the component that has signal changes is marked with the orange color border and the parents now have the ā¬ icon to tell that they have child views to refresh.
NOTE: We still depend on zone.js to trigger change detection.
The moment zone.js kicks in (the same reasons as before) it will call appRef.tick()
and then we will have top-down change detection with some differences and new rules!
Targeted Mode Rules
NgZone triggers change detection in GlobalMode
(it will go top-down checking & refreshing all components)
In GlobalMode
we check CheckAlways
(normal component without any change detection strategy set) and Dirty OnPush
components
What triggers TargetedMode?
- When in
GlobalMode
we encounter aNon-Dirty OnPush
component, we switch toTargetedMode
!
In TargetedMode:
- Only refresh a view if it has the
RefreshView
flag set - DO NOT Refresh
CheckAlways
or regularDirty
flag views - If we reach a view with
RefreshView
flag, traverse children inGlobalMode
Letās go one by one on components.
- Root component is a normal component (
CheckAlways
) so we just check & refresh bindings if needed and we continue to its children.
- All
CheckAlways
components will continue to work the same as before.
All CheckAlways components are refreshed
OnPush will continue to work the same, so if itās not marked as dirty it wonāt be checked.
If we check the other component that is OnPush + HAS_CHILD_VIEWS_TO_REFRESH but not dirty we get our trigger for
TargetedMode
(check the rules above)
- The component itself is not going to be refreshed, letās go to the children
TargetedMode on CheckAlways component -> Skip
- Then we reach a
RefreshView
component and we are onTargetedMode
, which means we refresh the bindings. We also convert toGlobalMode
to make sureCheckAlways
children components also get refreshed correctly.
- Now we are in
GlobalMode
and we have aCheckAlways
component, so we just refresh normally
Thatās all about the new Targeted Change Detection.
If we look at the final tree, we can see that we skipped more components than before when we reach an OnPush component that is not dirty.
Targeted Change Detection = OnPush without footguns š«
You can play with all these change detection rules in this app by Mathieu Riegler šØ
Angular Change Detection Playground
Zoneless Angular ā Letās remove zone.js from Angular
The moment we remove zone.js from Angular we are left with code that runs but doesnāt update anything in the view (The bootstrap time of zone.js and all the pressure it puts in the browser gets removed too! We also remove 15kb from the bundle size š). Because nothing is triggering appRef.tick()
.
But, Angular has some APIs that tell it that something changed. Which ones?
- markForCheck (used by async pipe)
- signal changes
- event handlers that mark view dirty
- setting inputs on components created dynamically with setInput()
Also, OnPush components already works with the idea that it needs to tell Angular that something changed.
So, instead of having zone.js schedule tick()
we can have Angular schedule tick()
when it knows something changed.
refactor(core): Add scheduler abstraction and notify when necessary
In this PR (experimental) we can see that markViewDirty now will notify the changeDetectionScheduler
that something changed.
export function markViewDirty(lView: LView): LView|null {
lView[ENVIRONMENT].changeDetectionScheduler?.notify();
// ... code removed for brevity
}
The scheduler is supposed to schedule tick(), as we can see in this other experimental implementation of a zoneless scheduler.
Add zoneless scheduler to the ApplicationRef.isStable indicator
@Injectable({providedIn: 'root'})
class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private appRef = inject(ApplicationRef);
private taskService = inject(PendingTasks);
private pendingRenderTaskId: number|null = null;
notify(): void {
if (this.pendingRenderTaskId !== null) return;
this.pendingRenderTaskId = this.taskService.add();
setTimeout(() => {
try {
if (!this.appRef.destroyed) {
this.appRef.tick();
}
} finally {
const taskId = this.pendingRenderTaskId!;
this.pendingRenderTaskId = null;
this.taskService.remove(taskId);
}
});
}
}
This is experimental, but we can see that basically, it will coalesce all notify() calls and run them only once (in this code is only once per macroTask ā setTimeout, but maybe we get it only once per microTask ā Promise.resolve())
What are we supposed to understand from this?
Apps that are currently using the OnPush change detection strategy will work fine in a zoneless angular world.
Zoneless Angular !== Glo-cal (local) change detection
Zoneless Angular is not the same as local change detection. Zoneless Angular is just removing zone.js from Angular and using the APIs that Angular already has to schedule tick().
Real Local change detection is a new feature that will allow us to run change detection for only a sub-tree of components (not the whole component tree) that currently use the OnPush change detection strategy.
Signals change detection (No OnPush, No Zone.js, Signals only)
One thing signal change detection will bring is native unidirectional data flow (two-way data binding without headaches).
Watch this video Rethinking Reactivity w/ Alex Rickabaugh | Angular Nation
While glo-cal change detection with OnPush and zoneless are great, with only signal components, we probably can do even better.
What if we didnāt have to use OnPush
? Or run change detection for the whole component tree? What if we could just run change detection for the views inside components that have changed?
Read more in the RFC:
Sub-RFC 3: Signal-based Components
Credits where itās due:
- Alex Rickabaugh for answering all my questions about Angular
- Andrew Scott for every PR he has created till now
- Chau Tran and Matthieu Riegler for the discussions about Angular internals
- Michael Hladky Julian Jandl Kirill Karnaukhov Edouard Bozon Lars Gyrup Brink Nielsen (RxAngular team)
- The whole Angular team for the wonderful job they have been doing!
Thanks for reading!
If this article was interesting and useful to you, and you want to learn more about Angular, support me by buying me a coffee āļø or follow me on X (formerly Twitter) @Enea_Jahollari where I tweet and blog a lot about Angular
latest news, signals, videos, podcasts, updates, RFCs, pull requests and so much more. š