Digest and Its Successor

Having spent years building applications with Angular.js, I still consider it a remarkably well-designed framework despite the criticism it often receives. My journey began with an excellent resource, Building your own Angular.js, and progressed through reading a substantial portion of its source code. This background gave me confidence in understanding its internals and the core ideas behind its architecture. Now, as I work to reach a comparable depth of understanding with the newer Angular, I've noticed something that contradicts the common narrative: the modern framework inherits more from its ancestor than most people acknowledge.

One notable example is the much-criticized digest loop:

This approach is notoriously expensive. Any modification to the application state triggers a cascade of function calls searching for changes. This is a core characteristic of Angular, imposing a strict ceiling on the UI complexity you can achieve without sacrificing performance.

That said, a deep understanding of how the digest operated allowed developers to build highly efficient applications. Techniques like deliberately invoking $scope.$digest() instead of relying on $scope.$apply everywhere, combined with the use of immutable objects, could mitigate much of the overhead. However, needing this level of internal knowledge just to achieve reasonable performance is likely a deal-breaker for many developers.

Given this history, it's no surprise that most tutorials claim the new Angular has eliminated the digest cycle. This assertion, however, depends heavily on how you define "digest." Based on its original purpose, I believe the claim is misleading. The mechanism is still present. We no longer work with explicit scopes, watchers, or calls to $scope.$digest(), but the underlying process—traversing the component tree, running implicit watchers, and synchronizing the DOM—has been reborn in a completely rewritten and greatly improved form.

This article will examine how the digest implementation differs between Angular.js and the newer Angular. It will be particularly valuable for developers migrating from Angular.js, as well as for those already using the modern framework who want to understand its foundations.

Why a Digest Is Necessary

Before diving into the details, let's recall the original problem the digest was designed to solve. Every framework must keep the data model (JavaScript objects) and the UI (browser DOM) in sync. The hardest part is determining exactly when the model has changed—a process known as change detection. How frameworks implement this is arguably the most significant differentiator among them today. I intend to write a comprehensive comparison of change detection strategies across various frameworks, so if you're interested, stay tuned.

There are fundamentally two approaches to detecting changes: one relies on the user to notify the framework, while the other involves automatic change detection through comparison. Consider the following object:

let person = {name: 'Angular'};

If we modify the name property, how does the framework know? One approach is to have the user inform the framework explicitly:

constructor() {
    let person = {name: 'Angular'};
    this.state = person;
}
...
// explicitly notifying React about the changes
// and specifying what is about to change
this.setState({name: 'Changed'});

Alternatively, the framework could enforce property wrappers, converting them into setters that can be intercepted:

let app = new Vue({
    data: {
        name: 'Hello Vue!'
    }
});
// the setter is triggered so Vue knows what changed
app.name = 'Changed';

The other major approach is to save the previous value of the name property and compare it against the current value:

if (previousValue !== person.name) // change detected, update DOM

But when should this comparison take place? Since code execution is usually triggered by asynchronous events—within a so-called Virtual Machine (VM) tick—the check can be scheduled for the end of that tick. This is precisely what the Angular.js digest does. We can therefore define the digest as:

a change detection mechanism that traverses the component tree, inspects each component for changes, and updates the DOM whenever a component property has changed

Using this definition, I maintain that the fundamental mechanism is still alive in the newer Angular. What has changed is its implementation.

The Angular.js Approach

In Angular.js, the core building blocks are the watcher and the listener. A watcher is a simple function that returns a value of interest—typically a property on the data model, but potentially anything else like a computed expression or component state. When the returned value differs from the value returned on the previous check, Angular.js invokes a corresponding listener, usually responsible for updating the DOM.

Here's how the $watch function parameterizes these elements:

$watch(watcher, listener);

For example, if we have a person object with a name property displayed in the template as <span>{{name}}</span>, we could track changes and update the DOM like this:

$watch(() => {
    return person.name
}, (value) => {
    span.textContent = value
});

Interpolations and directives such as ng-bind work in essentially the same way. In Angular.js, directives carry the responsibility of reflecting model changes in the DOM. The newer Angular, however, replaces this with a system of property bindings that directly connect the model to the DOM. Our example would now be written like this:

<span [textContent]="person.name"></span>

Just as components form a hierarchy, on the Angular.js side there exists a matching hierarchy of watchers. These are grouped under $scope objects, though the exact grouping is not crucial to this discussion.

During a digest cycle, Angular.js systematically walks this watcher tree, updating the DOM as needed. The cycle is typically triggered automatically on every asynchronous event when using built-in services like $timeout or $http, or on demand via $scope.$apply() and $scope.$digest().

The update order is strict: parent watchers are processed before child watchers. While predictable, this has unfortunate consequences. A listener executing on a child watcher might update a property of the parent. If the parent's watchers have already processed, those changes would be missed. To handle this, the digest loop is designed to run multiple times, ensuring the system becomes stable and no further changes are detected. A hard cap of ten iterations is imposed. In hindsight, this design is considered flawed, and the new Angular has taken a different route to avoid it entirely.

The Angular Way

The modern Angular framework doesn't offer a public concept that directly corresponds to an Angular.js watcher. Still, the functions that track model property changes certainly exist. Now, however, these update functions are code-generated by the compiler and are kept out of reach from the developer. They are also intimately tied to the underlying DOM. These functions are stored under the updateRenderer property on a view.

These generated functions are also more specialized than their predecessors. Instead of watching anything, they track only model properties that are referenced in the template. Each component receives one update function which wraps all the properties used in its template. Rather than returning a value, this function calls checkAndUpdateTextInline for each property. This helper compares the previous value with the current one and, when necessary, directly updates the DOM.

Take the AppComponent as an example. If its template looks like this:

<h1>Hello {{model.name}}</h1>

the compiler will automatically generate code resembling the following:

function View_AppComponent_0(l) {
    // jit_viewDef2 is `viewDef` constructor
    return jit_viewDef2(0,
        // array of nodes generated from the template
        // first node for `h1` element
        // second node is textNode for `Hello {{model.name}}`
        [
            jit_elementDef3(...),
            jit_textDef4(...)
        ],
        ...
        // updateRenderer function similar to a watcher
        function (ck, v) {
            var co = v.component;
            // gets current value for the component `name` property
            var currVal_0 = co.model.name;
            // calls CheckAndUpdateNode function passing
            // currentView and node index (1) which uses
            // interpolated `currVal_0` value
            ck(v, 1, 0, currVal_0);
        });
}

So while the watcher has evolved, the digest loop itself remains. The new incarnation is now referred to as a change detection cycle:

In development mode, tick() also performs a second change detection cycle to ensure that no further changes are detected.

I previously described how Angular.js walks the watcher tree during the digest. Angular does virtually the same thing. During its change detection cycle, it traverses the component tree, invoking the renderer's update functions along the way. This is part of the check-and-update view process, which I've examined at length in Everything you need to know about change detection in Angular.

In both frameworks, the change detection cycle is triggered on every asynchronous event. But Angular has a distinct advantage: it uses zones to patch all asynchronous APIs, meaning most events no longer need any manual trigger. The framework subscribes to the onMicrotaskEmpty event and is automatically notified when an async operation concludes. This event fires once no more microtasks are queued within the current VM tick. But even with this automation, you can still invoke change detection manually through view.detectChanges or ApplicationRef.tick.

A key architectural rule called unidirectional data flow from top to bottom is enforced in Angular. A child component is prohibited from updating a parent's properties after the parent's changes have been processed. If it attempts this from the DoCheck hook, there is no problem, as this hook fires before property checks. But if the update happens later—for example, from the AfterViewChecked hook which is invoked post-processing—the development mode fails with:

Expression has changed after it was checked

This error is explored in greater detail in Everything you need to know about the `ExpressionChangedAfterItHasBeenCheckedError` error.

When running in production mode, this condition doesn't throw an exception, but those changes will not be reflected until the next change detection cycle starts.

Tracking changes with life-cycle hooks

In angular.js, each component relies on a collection of watchers to monitor the following:

  • bindings passed from a parent component
  • properties owned by the component itself
  • values that are derived through computation
  • external widgets that exist outside the Angular environment

These tracking mechanisms can be recreated in Angular using specific hooks. For monitoring bound properties from a parent component, the OnChanges life-cycle hook is the appropriate choice.

To keep tabs on the component’s own properties and to calculate derived values, the DoCheck hook fits the bill. Because this hook runs before Angular applies property changes to the current component, it gives you the chance to make any necessary adjustments so the UI reflects the latest state correctly.

For watching external widgets that are not part of the Angular ecosystem, the OnInit hook can be used to kick off change detection manually as needed.

Consider a component that shows the current time, where the time comes from a Time service. In angular.js, the implementation would look like this:

function link(scope, element) {
    scope.$watch(() => {
        return Time.getCurrentTime();
    }, (value) => {
        $scope.time = value;
    })
}

In Angular, the same functionality is implemented as follows:

class TimeComponent {
    ngDoCheck()
    {
        this.time = Time.getCurrentTime();
    }
}

As another illustration, imagine a third-party slider component that is not integrated into the Angular ecosystem, but you still need to display the active slide. The solution is to wrap the slider in an Angular component, listen for the slider’s changed event, and manually trigger a digest so the UI updates accordingly:

function link(scope, element) {
    slider.on('changed', (slide) => {
        scope.slide = slide;
        
        // detect changes on the current component
        $scope.$digest();
        
        // or run change detection for the all app
        $rootScope.$digest();
    })
}

The approach translates directly to Angular. Here is the equivalent implementation:

class SliderComponent {
    ngOnInit() {
        slider.on('changed', (slide) => {
            this.slide = slide

            // detect changes on the current component
            // this.cd is an injected ChangeDetector instance
            this.cd.detectChanges();

            // or run change detection for the all app
            // this.appRef is an ApplicationRef instance
            this.appRef.tick();
        })
    }
}

And that wraps it up.