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:
- State is presumed to change only within asynchronous contexts — event handlers,
Promiseresolutions, orsetTimeout/setIntervalcallbacks. - These native async operations are intercepted by
zone.js. - Upon any such event, Angular's change detector is invoked.
- The detector walks the component tree, comparing current data to previous values.
- 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
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
}
}
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:
- Get a reference to the component's
ChangeDetectorRef. - Call
detachto stop automatic change detection for this component. - Use a micro-task (or
setTimeoutin this case) to immediately run change detection once, ensuring the component shows its initial state. - Create the
Proxyover the supplied object. - The
gettrap simply returns the requested property value. - The
settrap updates the property and then callsmarkForCheck()to manually trigger the view update. - 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];
},
});
}
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;
}
}
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';
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'};
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];
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();
}
}
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.
