Angular Change Detection — Today and Tomorrow

🪄 OZ 🎩
6 min readDec 17, 2023
“On Lake Attersee”, Gustav Klimt, 1900

Going step by step, from very simple and basic things to more advanced ones, I’ll explain how Angular Change Detection works, why ZoneJS was created, and how things are going to be improved.

We have a tree of components in our application, and each of them has a template. Templates describe what DOM elements Angular should create, and what properties DOM nodes should have.

The template is just a blueprint, a set of instructions. The process of converting these instructions into real DOM nodes is called “rendering” and Angular does it for us.

So far so good and simple, but eventually we want to modify the DOM — that’s how web applications work. To do this, we modify instructions in our template and expect that it will affect the DOM — instructions have changed, so Angular should re-render the DOM nodes. That’s a fair expectation. Angular should react to the changes in the templates.

Example:

<div>
<button (click)="areaDisabled = !areaDisabled">Editable</button>
<textarea [attr.readOnly]="areaDisabled ? 'true': null"></textarea>

<button (click)="counter++">Increment</button>
<div>{{counter}}</div>

</div>

In this example, we have expressions that declare how DOM nodes should be rendered:

  • [attr.readOnly]=”areaDisabled ? ‘true’: null”
  • {{counter}}

Angular will not just re-render every template — that would be too slow. Instead, Angular will compare expressions in the template, and if some of them have changed in some template, then that template will be re-rendered. This is called Change Detection (CD).

Also, Angular will not run this process in an endless loop (like we do when creating games). To see if some expression is changed, Angular should compare the new value with the previous value. To get a new value of the expression, this expression should be executed (evaluated). While it is not so “expensive” operation, running it in an endless loop would make our application unusably slow (and would even freeze the browser tab quickly).

So, when to run a Change Detection cycle?

Right now, Angular uses ZoneJS to answer this question.

ZoneJS monkey-patches all the async APIs, so things like setTimeout() or Promises will schedule a Change Detection cycle.

ZoneJS monkey-patches most of the DOM events as well, and they also will schedule a CD cycle.

While there are no DOM events, and no async code execution — no CD cycles will be scheduled and our browser has time to rest (and render the changes we requested).

One question is answered, and one problem is solved. But Angular still checks all the expressions in every component to detect changes. Most of the time we don’t change every component simultaneously in response to user actions, usually we modify just a few components, or even only one. So, how to let Angular know what components should be checked?

How to skip non-changed components?

For this, we can mark a component’s template (a view, in Angular’s internal terminology) as “dirty” when we change some expression in that template. Then we can use the OnPush change detection strategy, and Angular, during a Change Detection run, will skip all the components, that are not marked as dirty. It significantly decreases the amount of expressions Angular has to re-evaluate on every CD run.

That all sounds great, but who exactly should mark the template as dirty? A programmer? Manually? After every change of variables?
No, that would be too cumbersome.

In practice, we use a combination of Observables and async pipe — all the bindings and expressions are provided by Observables, and async pipe takes care of subscribing, listening to new values produced, and marking the view as dirty.

“But why the hell do we need Observables here?”, some readers will ask, “can’t we just use regular variables?”

The problem with regular variables, is they don’t notify anyone when we change them, they remain silent, so we have no chance to react. Observables can do this and when they do, the async pipe marks a view as dirty.

We use Observables as a source of reactivity — async pipe reacts to the changes and marks views as dirty, and because Observables use async APIs, that are patched by ZoneJS, changes will also schedule a CD cycle.

Angular Signals is the new way to add reactivity to our templates — they also notify the framework, when their values are changed. It is their primary purpose. Signals can coexist with the observables right now, but in the future, signals are going to be the main way to express reactivity in Angular templates (no need to worry — your existing apps with observables and async pipe will not stop working).

How to find changed components?

Right now, there is one small limitation in this mechanism. Every time Angular runs a Change Detection cycle, it goes from a root component to its children, and then to grand-children. As I wrote, we have a tree of components in our application, and a CD cycle will recursively walk through this tree.

Angular will skip components that have the OnPush strategy and are not marked as dirty — that’s excellent, but what if children or grandchildren of a skipped component are marked as dirty? How Angular will find them?

Here is a dirty truth: when you mark a view as dirty, Angular will mark all its ancestors as dirty. Recursively, until the root of the tree is reached. It is a necessary trade-off to let Angular find all the views, marked as dirty. It adds some extra components to the list, but it is not as many components as CD would check with a “default” strategy.

Besides asynchronous code, we still have DOM events. If in the template we are listening for DOM events, they will also mark a view (and all its ancestors) as dirty. In our example, we are listening for the click event: <button (click)=”counter++”>Increment</button>. Because of that, every click event will mark our component and all its ancestors as dirty.

How to make it better?

Obviously, we would like to have a perfect Change Detection mechanism that would “execute” only these views, that are marked as dirty, without any extra views. Also, we would like to schedule Change Detection cycles only when we need them, not just because some DOM event was emitted — we don’t usually change our views as often as mousemove being emitted.

To reach these results, the Angular team created Angular Signals and going to improve the Change Detection scheduling mechanism, to schedule CD without ZoneJS.

Right now (in v17), if your component uses the OnPush strategy and the only source of reactivity in the template is signals, then signals will mark this component as dirty, but will not mark its ancestors as dirty — ancestors will be marked only “for traversal”, and Change Detection will still traverse the ancestors, but will not “execute” them (will not check if their expressions gave the new results).

It is called “Local Change Detection”. Sounds pretty awesome, but it only works if the change is caused by an asynchronous code, not right after a DOM event — because, as I wrote above, DOM events will mark all the ancestors as dirty. It is still a wonderful step forward and I’m quite happy that we have it.

Components that are compatible with the OnPush strategy will get a more impressive update in the future: they will be able to work without ZoneJS (CD cycles will be scheduled without ZoneJS). Components are “OnPush compatible” when all the values in the template are reactive, which means that the template reads them from signals or observables.

And, finally, signal-based components (the new kind of components, not released yet) will get a brand new Change Detection: only changed views will be checked by CD, no ZoneJS will be required to schedule a CD cycle. The perfect Change Detection that we were dreaming about 😉

Update:

You can already try the new zoneless mode, using the function ɵprovideZonelessChangeDetection() . For that, your app just should be OnPush compatible (see above).

If your app or third-party libraries use Zone.onStable() or Zone.onMicrotaskEmpty(), you might need this code snippet:

If you are not too tired yet — read these slides or play with this app.

--

--