Original cover photo by Adi Goldstein on Unsplash.
What's the problem?
In Angular, we have the powerful change detection mechanism to help us rerender the UI when data changes.
In simple terms, this works in the following way:
- We assume state only changes on async events (clicks and other browser event,
Promise
resolve,setTimeout
/setInterval
) - Angular uses
zone.js
to monkey patch async events - When an async event happens, Angular calls the change detector
- The change detector traverses the tree of components and checks if any of the data has changed
- If so, it rerenders the UI
This process is overall known as change detection. Notice that the change detector will definitely be invoked in situations where no changes have been made at all, making it less efficient than we would ideally want.
We can do some optimizations, like using the ChangeDetectionStrategyOnPush
to help the change detector work better. Or we can detach
the change detector from some components if we know that they do not need change detection (a very rare scenario).
But can anything be done to make this work better? We know we can manually trigger the change detection process via a reference to the change detector (the ChangeDetectorRef
class).
But how do we recognize when we need to manually trigger the change detection process? How do we know a property has changed? Also, how do we obtain the change detector reference outside of a component, so we can solve this problem with a generic function?
Let's try and address all of these questions using the new features provided by Angular version 14, and some JavaScript magic.
Disclaimer: the following code examples are an experiment, and I do not encourage you to try using this in production code (at least yet). But this approach is an interesting avenue to investigate
Enter Proxy
objects
If you are unfamiliar with Proxy
objects, as we are going to use them, let's explore them a bit. Proxy
in JavaScript is a specific class, which wraps around a custom object, and allows us to define a custom getter/setter function for all properties of the wrapped object, while simultaneously from the outside world, the object looks and behaves as a usual object. Here is an example of a Proxy
object:
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
Now, what if we have Proxy
objects in our app, which will call the change detector manually when the properties are changed? The only remaining caveat is obtaining the reference to the specific component's change detector reference. Thankfully, this is now possible with the new inject
function provided in Angular version 14.
Inject?
inject
is a function that allows us to obtain a reference to a specific token from the currently active injector. It takes a dependency token (most commonly a service class or something similar) as a parameter, and returns the reference to that. It can be used in dependency injection contexts like services, directives, and components. Here is a small example of how this can work:
@Injectable()
class MyService {
http = inject(HttpClient);
getData() {
this.http.get('my-url'); // no constructor injection
}
}
Aside from this, we can also use this in other functions, provided these functions are called from DI contexts as mentioned. Read more about the inject
function in this awesome article by Netanel Basal
Now, with this knowledge, next we are going to create a function that helps us ditch the automatic change detection but still use Angular (more or less) as usual.
So what's the solution?
We are going to create a function that makes a proxy of an object which manually triggers the change detection process when a property is changed. It will function as follows:
- Obtain a reference to the change detector of the component
-
detach
the change detector; we don't need automatic change detection - using
setTimeout
, perform the change detection once after the function is done (so that initial state is reflected in the UI) - Create a proxy from the plain object
- When an object property is called (get), we will just return the value
- When an object property is set, we will set the value and manually trigger the change detection
- Observe how the UI changes
Here is the full example:
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 how this in action:
@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;
}
}
Now this works as any other Angular component would work, but it won't be checked for changes on other change detection iterations.
Caveats
Nested plain objects
Nested object property changes won't trigger a UI update, for example
this.vm.user.name = 'Armen';
Won't trigger change detection. Now, we can make our function recursive so that it makes a sport of "deep" Proxy
object to circumvent this constraint. Or, otherwise, we can set a new reference to the first-level object instead:
this.vm.user = {...this.vm.user, name: 'Armen'};
I personally prefer the latter approach, because it is more explicit and does not involve nested object mutations.
Array methods
With this approach, we cannot count on functions like Array.push
to update the DOM, instead we would need to do the same thing as in the previous example:
// instead of this
this.vm.item.push(item);
// we will have to do this:
this.vm.items = [...this.vm.items, item];
Input properties
As we have detached the change detector, if the component has properties decorated with @Input()
, the change detection will not be triggered and we won't see new values from the outside world. We can circumvent this using this approach:
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();
}
}
This solves the problem, but does not look very pretty.
In Conclusion
This approach is, of course, experimental, but it provides an interesting insight into how Angular operates, and how we can make tweaks to boost performance without sacrificing code quality.
Top comments (1)
This post is really amazing for me!! Vashikaran Specialist In Nashik