Original cover photo by Adi Goldstein on Unsplash.

Identifying the core issue

Angular's built-in change detection is a robust tool for keeping the view in sync with application state. The mechanism, stripped down, operates along these lines:

  1. State is presumed to change only within asynchronous contexts — event handlers, Promise resolutions, or setTimeout/setInterval callbacks.
  2. These native async operations are intercepted by zone.js.
  3. Upon any such event, Angular's change detector is invoked.
  4. The detector walks the component tree, comparing current data to previous values.
  5. Any differences are reflected by a re-render.

This entire cycle is referred to as change detection. A notable drawback is that the detector is always triggered, even when the application state remains unchanged, which leads to more work being done than is strictly necessary.

There are standard optimization strategies to mitigate this, such as employing the ChangeDetectionStrategyOnPush or using detach on a component's change detector if you are certain it doesn't need to be checked — a scenario that is not very common in practice.

The question is whether we can improve upon this. We have the ability to trigger change detection on demand through the ChangeDetectorRef class. The challenge lies in determining *when* it's necessary. How can we detect that a property's value has been modified? And how can we get a reference to the change detector from outside a component to build a general solution?

Let's explore these questions in the context of Angular 14's new capabilities and a bit of JavaScript's metaprogramming power.

Note: The following code is a proof-of-concept and is not advised for production use. It is presented as an interesting area of exploration for understanding the framework's internals.

Introducing JavaScript's Proxy objects

Before we dive into the solution, let's briefly cover the Proxy object, which is central to our approach. A Proxy in JavaScript is a special class that acts as a wrapper around a target object. It allows us to define custom behavior for fundamental operations like property access (get) and assignment (set), all while appearing to the outside world as a standard object. The following example illustrates a basic Proxy setup:

const obj = new Proxy({text: 'Hello!'}, {
    set: (target, property: string, value) => {
        console.log('changing');
        (target as Record<string, any>)[property] = value;
        return true;
    },
    get(target, property: string) {
        // just return the state property  
        return (target as Record<string, any>)[property];
    },
});

console.log(obj.text); // logs 'Hello!'
obj.text = 'Bye!'; 
// logs 'changing' and 'World' because the setter function is called
Enter fullscreen mode Exit fullscreen mode

So, what if the objects we bind to our components were Proxies that, when their properties are changed, also trigger change detection? The main hurdle is getting a reference to the correct component's change detector from outside of its template. This is now achievable thanks to the inject function introduced in Angular version 14.

The inject function

inject is a new utility that grants access to the dependency injection (DI) system. It accepts a DI token, typically a class, and returns the corresponding instance from the active injector. It can be used in class-based components, directives, and services — anywhere within a DI context. Here’s a quick demonstration of its usage:

@Injectable()
class MyService {
    http = inject(HttpClient);

    getData() {
        this.http.get('my-url'); // no constructor injection
    }
}
Enter fullscreen mode Exit fullscreen mode

It's important to note that inject can also be called from within other functions, provided they are executed in a DI context. For a more in-depth explanation, see this article by Netanel Basal: "Unleash the Power of DI Functions in Angular".

With these tools in hand, we can now create a function to manage our own change detection, side-stepping the framework’s automatic process while still working within Angular's ecosystem.

The proposed solution

Our objective is to create a function that wraps a plain object in a Proxy. When any of the object's properties are set to a new value, this proxy intercepts the assignment and, in addition to updating the value, calls markForCheck() on the component's change detector. Let's break down the steps:

  1. Get a reference to the component's ChangeDetectorRef.
  2. Call detach to stop automatic change detection for this component.
  3. Use a micro-task (or setTimeout in this case) to immediately run change detection once, ensuring the component shows its initial state.
  4. Create the Proxy over the supplied object.
  5. The get trap simply returns the requested property value.
  6. The set trap updates the property and then calls markForCheck() to manually trigger the view update.
  7. Observe the change detection happening without Angular’s involvement.

Here is the implementation:

function useState<State extends Record<string, any>>(state: State) {
    const cdRef = inject(ChangeDetectorRef);
    cdRef.detach(); // we don't need automatic change detection
    setTimeout(() => cdRef.detectChanges()); 
    // detect the very first changes when the state initializes
    return new Proxy(state, {
        set: (target, property: string, value) => {
            (target as Record<string, any>)[property] = value; 
            // change the state
            cdRef.detectChanges();
            // manually trigger the change detection
            return true;
        },
        get(target, property: string) {
            // just return the state property
            return (target as Record<string, any>)[property];
        },
    });
}
Enter fullscreen mode Exit fullscreen mode

Now, let's see this function in action within a typical component:

@Component({
    selector: "my-component",
    template: `
    <div>
        {{text}}
    </div>
    <button (click)="onClick()">Click me!</button>
    `
})
export class MyComponent {
    vm = useState({text: 'Hello, World!'}); // now we have a state

    onClick() {
        this.vm.text = "Hello Angular";
        // works as expected, changes are detected
    }
    get text() {
        console.log('working');
        return this.vm.text;
    }
}
Enter fullscreen mode Exit fullscreen mode

This component behaves like any other Angular component from a user's perspective, but it will not be checked during the standard change detection cycle. Updates are entirely self-driven.

Important considerations and limitations

Handling nested objects

If you attempt to modify a property on a nested object, the UI will not receive an update. For example:

this.vm.user.name = 'Armen';
Enter fullscreen mode Exit fullscreen mode

The change detection won't be triggered by this assignment. To address this, we could either make the Proxy recursive, creating a "deep" proxy for all nested objects. Or, more simply, we can replace the top-level object with a new reference:

this.vm.user = {...this.vm.user, name: 'Armen'};
Enter fullscreen mode Exit fullscreen mode

I lean towards the latter approach because it keeps mutations explicit at the top level and avoids unwieldy nested object operations.

Array methods

With this specific Proxy implementation, methods like push won't cause a UI update. Instead, you would need to create a new array or reassign the whole array property, just as you would with a nested object:

// instead of this
this.vm.item.push(item);

// we will have to do this:
this.vm.items = [...this.vm.items, item];
Enter fullscreen mode Exit fullscreen mode

Interactions with @Input()

Since we've detached the change detector, it will ignore any changes to component's @Input() properties. Any new values passed from a parent will not be reflected. One workaround is to bind to setter functions via the @Input() facade to update the values and manually trigger the change detection:

export class MyComponent implements OnChanges {
    @Input() value = '';
    vm = useState({text: 'Hello, World!'}); // now we have a state
    cdRef = inject(ChangeDetectorRef);

    onClick() {
        // works as expected, changes are detected
        this.vm.text = "Hello Angular";
    }

    ngOnChanges() {
        // detect input changes manually
        this.cdRef.detectChanges();
    }
}
Enter fullscreen mode Exit fullscreen mode

While this approach gets the job done, it introduces boilerplate that can make the code less readable and maintainable.

Final Thoughts

Admittedly, this technique is experimental in nature. Still, it sheds light on Angular's internal mechanics and shows how small adjustments can enhance performance while keeping the codebase clean and maintainable.