DEV Community

Cover image for Reactivity in Angular
Evgeniy OZ
Evgeniy OZ

Posted on • Originally published at Medium

Reactivity in Angular

This article explains why we should build our Angular apps with reactivity in mind and how immutability helps with it.

The Magic

Look at this example:

    @Component({
      template: `
        <div>Name: {{user.name}}</div>
        <div>Age: {{user.age}}</div>

         <button (click)="updateName()">Set Random Name</button>
         <button (click)="updateAge()">Set Random Age</button>
      `,
    })
    export class App {
      user = {
        name: 'Alice',
        age: 25,
      }

      updateName() {
        const i = Math.floor(Math.random() * names.length);
        this.user.name = names[i];
      }

      updateAge() {
        this.user.age = Math.floor(Math.random() * 20 + 20);
      }
    }
Enter fullscreen mode Exit fullscreen mode

It works, but it only works because of magic. Dark magic. I wish it didn’t work :) What’s much worse is that code like this is scattered across Angular tutorials for beginners, so it shouldn’t be surprising that many Angular apps have similar code, and that many readers right now are asking, “What the hell is wrong with this code, dude? It just works. Chill, man.”

What works

In our template, we have bindings:

    <div>Name: {{user.name}}</div>
    <div>Age: {{user.age}}</div>
Enter fullscreen mode Exit fullscreen mode

When user.name changes, Angular reflects this change and updates the HTML element. That seems quite reasonable and logical.

But how does Angular know that user.name has changed? We didn’t call anything like heyAngularWeChangedTheUserObject() or even checkBindingsPlease(). This is where the dark magic hides — and this magic has its price.

What is the price

The magic tricks here are “dirty checking” and “monkey patching”.

They hurt performance and can cause data pollution (undesired mutations) in non-trivial apps. But they work just fine in small apps, especially in the simple examples provided in tutorials.

To cast the “dirty checking” spell, Angular has to check every binding (and every “input”) in every component of your application. Some of these components are used in “for” loops, some are reused multiple times, so the number of rendered components might be higher than the total number of components your app has.

And Angular doesn’t know when your bindings might change. So Angular reacts to every change of every [input] and (output) and DOM event listeners like (click). And still, it is not enough.

Data in your app might change asynchronously. Not right after the (output) has emitted (for example), but a little bit later, after loading some data. And Angular will run its dirty checking too early to notice the changes.

For that, Angular uses another spell: “monkey patching.”

A special tool, Zone.js, wraps asynchronous APIs like setTimeout, requestAnimationFrame, Promise, MutationObserver, and events like click, change, mousemove, and many others [1]. So when they are called, Zone.js notifies Angular — and that’s how it knows when to cast the “dirty checking” spell.

Did you just say mousemove?

Yes. And scroll is also patched. Now you can imagine how many times Angular has to check every binding in your components.

The existence of this dark magic led to a special rule:

Thou shalt not call functions in your templates! ☝️

Even beginners know this rule, because it’s practically a commandment in many tutorials.

The reason: comparing two variables by reference is a very cheap operation in terms of performance. But if a binding involves a function, that function will be called, and computing its value might not be so cheap.

This rule is nothing but an adaptation to the shortcomings of the dark magic we just explored.

Is the OnPush Change Detection Strategy Enough?

There are two change detection strategies in Angular that components can use, and one of them, OnPush, instructs the framework to check not every component in the app, but only the modified component and its ancestors.

But just changing the strategy in an existing component is not enough. First, every binding should be reactive — otherwise, we’ll do more harm than good.

Let’s modify our example a bit:

    @Component({
      changeDetection: ChangeDetectionStrategy.OnPush,
    // ...

      updateAge() {
        // Any asynchronous code.
        // Imagine we are loading the allowed ages list.
        setTimeout(() => {
          this.user.age = Math.floor(Math.random() * 70 + 20);
        }, 1);
      }
    }
Enter fullscreen mode Exit fullscreen mode

Now, if you click “Set Age” first, you will see no changes. But if you click “Set Name” afterwards, you’ll see both the name and the age updated.

Explanation of the bug:

  1. When we click the button, the event handler not only calls updateAge(), but also marks our component as “dirty” and schedules a Change Detection cycle;
  2. Change Detection runs, but can’t see any changes because they will be applied later (asynchronously). It removes the “dirty” mark from our component;
  3. setTimeout() is patched by Zone.js, so after 1ms, it schedules a Change Detection cycle;
  4. Change Detection runs, but our component has the OnPush strategy and is not marked as “dirty” (see step 2), so the component is skipped;
  5. When we click another button, the event handler calls updateName(), marks our component as dirty, and schedules a Change Detection cycle;
  6. Change Detection runs and sees changes in both of our bindings: user.name, which we just changed, and user.age, which we changed previously. Both changes are then reflected.

This is an example of why we should not use OnPush with non-reactive bindings.
We should only use OnPush when every binding in our template is reactive.

As you can see from the steps 3 and 4, Zone.js can notify the framework about when, but not where a change detection is required.

Angular is moving away from black magic. In the Zone.js repo, you can find [2]:

As Angular moves towards a zoneless application development model, Zone.js is no longer accepting new features, including additional patches for native platform APIs. The team will also not be accepting any low priority bug fixes. Any critical bug fixes that relate to Angular’s direct use of Zone.js will still be accepted.

And this is the direction we all should move as well — towards…

Pure Reactivity

If we notify the framework that some parts of our templates have changed, then the framework will not need all that dark magic and can:

  • Check only the modified parts;
  • Only when needed;
  • Optimize DOM updates even with asynchronous changes;
  • Stop using monkey patching.

It might sound too good to be true, but we can do this already — starting with Angular version 20! We’ll need 3 things:

  • bindings in our templates should use only Signals;
  • every component should use OnPush change detection strategy;
  • provideZonelessChangeDetection() in bootstrapApplication() providers.

If, for some reason, you can’t use Angular v20 yet, or some old libraries are not compatible with zoneless change detection — don’t worry. If you can use Signals for bindings (or at least the async pipe with Observables), and all your components use the OnPush change detection strategy, the performance of your app will be quite close to that of a zoneless app.

An additional benefit of a zoneless app: it forces your code not to rely on Zone.js and to be purely reactive.

Reactive bindings

The point of reactive bindings is to notify Angular about the changes (when and where they happened), so Angular can react (pun intended) and reflect the changes with minimal performance costs.

Let’s fix our bug by replacing our non-reactive bindings with reactive ones:

Let’s analyze our code:

    @Component({
      selector: 'app-root',
      changeDetection: ChangeDetectionStrategy.OnPush,
      template: `
        <div>Name: {{name()}}</div>
        <div>Age: {{age()}}</div>
        ...
      `,
    })
    export class App {
      user = signal<User>({
        name: 'Alice',
        age: 25,
      });

      name = computed(() => this.user().name);
      age = computed(() => this.user().age);

      updateName() {
        const i = Math.floor(Math.random() * names.length);
        this.user.update((user) => ({
          ...user,
          name: names[i],
        }));
      }

      updateAge() {
        setTimeout(() => {
          this.user.update((user) => ({
            ...user,
            age: Math.floor(Math.random() * 70 + 20),
          }));
        }, 1);
      }
    }
Enter fullscreen mode Exit fullscreen mode

Now user is a signal, we have two derived (computed) signals: name and age, and our updateName() and updateAge() methods have been modified a little.

Every time we modify the user signal, it sends notifications to name and age, and they send notifications to the template.
When this happens, the template marks our component as “dirty” and schedules a Change Detection cycle.

Without any magic, we let Angular know where and when changes happened, so Angular can granularly update only the needed parts — and only when it’s needed.

But wait, we are calling functions in our template! 😱

Yes, and it’s ok. Signals are functions, but their call is super cheap because they don’t compute their value on every call. They memoize their value, so calling them doesn’t hurt performance.
In fact, any function that memoizes its value can be safely called from the template [3]. Signals just guarantee this out of the box.

Immutability

In the updateName() and updateAge() methods, we use the update() method of an Angular Signal and create a shallow copy of the existing object. Why not just mutate the fields of the existing object?

Because when we update an Angular Signal using set() or update(), Angular compares the new value with the old one. A notification will be sent only if they are not equal. And they are compared by reference (===).

Two references to the same object are always considered equal — and that’s why we create a shallow copy: to trigger the value change notification.

You can verify this behavior in the following new methods:

    mutateAge() {
      const user = this.user();
      user.age = Math.floor(Math.random() * 70 + 20);
    }

    mutateAgeAndSet() {
      const user = this.user();
      user.age = Math.floor(Math.random() * 70 + 20);
      this.user.set(user);
    }
Enter fullscreen mode Exit fullscreen mode

In mutateAge(), we mutate the field of a signal value. As you can check, this will not work.

In mutateAgeAndSet(), we mutate the field and call the signal’s set() method. It will compare two user objects, they are equal by reference, so this will not trigger any changes either.

You can read more about Angular Signals reactivity and mutations in my other article [4].

There is an easy trick to protect ourselves from this mistake:

    export type User = {
      readonly name: string;
      readonly age: number;
    };
Enter fullscreen mode Exit fullscreen mode

There are more advanced tools to provide immutability, but this one doesn’t require any libraries to use.

Immutability is not only helpful for reactivity. It has other benefits: your data will not be polluted (modified by code that has no clue it’s modifying shared data), it protects from unexpected side effects, and it makes testing easier.

It is possible to override the default equality check function in Angular Signals (in most of their kinds), but you should only do this in exceptional cases, when providing the new values in an immutable way is more expensive than checking equality manually.
Checking equality in nested objects recursively can be quite expensive in terms of performance and might require special handling if the objects have circular references.

Performance

Let’s explore why the Signals in Templates + OnPush + Zoneless formula provides the best possible performance for your Angular app.

A template is a consumer of signals and behaves similarly to effect(), but it also schedules a Change Detection cycle when a signal is updated.

Unlike the async pipe [9], the template will not be triggered on every update of the signal. As we learned above, only non-equal values will trigger an update.
Also, if a signal is updated 100 times synchronously (each time with a new value), the template will schedule just one Change Detection cycle.

Here is the difference in how Observables and Signals deliver their update notifications:

  • Observables emit every time we ask them to. They don’t check if the next value is equal to the previous one. When an Observable emits a value, the subscriber must handle it, because it can’t predict whether there will be another. We could use debounceTime(0), but that would introduce unnecessary asynchrony when a synchronous update would be sufficient;
  • Consumers of signals receive only a notification about the update, not the new value. To get the new value, they must read the updated signal — and they are free to do this whenever they want. In the case of computed signals, this means that the new value will be recomputed not at the moment when the signals used in the computation are updated, but when the consumer reads the value of that computed signal. This can eliminate a lot of unnecessary computations, because consumers (effect(), templates) schedule the reads of the signals they are subscribed to, so they don’t read more often than needed. You can read more about signals timings in the article linked below [6].

When the OnPush strategy is combined with signals (your template uses only signals for reactivity and all components use the OnPush strategy), Angular applies an optimization called “Local Change Detection” [5]. It doesn’t mark every ancestor as “dirty”; instead, it marks only the component as “dirty” and marks each ancestor “for traversal.”
This means that non-dirty ancestors will not be checked.

And when every binding (that can change) in our templates uses a signal, we simply don’t need Zone.js and its performance overhead — Angular already knows where and when changes happened.

Combined with the “Single Source of Truth” and “Template First” approaches ([7], [8]), this article will help you create super-performant, declarative, and scalable code. **To the stars! 🚀**

Links:

  1. Zone.js’s support for standard apis” (permalink).
  2. Development Status of Zone.js” (permalink).
  3. It’s ok to use function calls in Angular templates!” by Enea Jahollari.
  4. Angular Signals: Keeping the Reactivity Train”.
  5. Local Change Detection in Angular 17” by Ilir Beqiri.
  6. Angular Signals — Timing”.
  7. Angular Inputs and Single Source of Truth”.
  8. Creating Angular Components: Template-First Declarative Approach”.
  9. StackBlitz, example with async pipe.
  10. StackBlitz, example 1.
  11. StackBlitz, example 2.
  12. StackBlitz, example 3.

Top comments (0)