Original cover photo by Kendall Ruth on Unsplash.
Angular is a huge framework with lots of built-in functionality and lots of features and tweaks. While the documentation and a myriad of training resources explain how it works and all of its components in great detail, there are still some misconceptions about certain aspects of Angular that are worth clearing up.
So, in this article, we will explore concepts that are sometimes misinterpreted, even by the most seasoned Angular developers.
1. Zone.js is doing change detection
“You need zone.js to have change detection, without it, your application will be frozen in time” – this is what often is said to newcomers in Angular, and it is entirely true. But… that is only true in the most basic sense. Zone.js is not doing change detection, it is just enabling it.
Let’s understand how it works in detail:
- Angular updates the UI only if some changes have been met to the application state.
- Angular assumes (quite correctly) that any change to the application can occur only when something asynchronous happens (e.g. a user clicks a button, or a network request completes, or a
setTimeout
fires, and so on). - Zone.js monkey-patches all the asynchronous APIs in the browser, so those can be hijacked and made to dispatch notifications
- Angular listens to those notifications, and if something happens, it triggers a top-down change detection process, moving through components, finding out what changed, and, if needed, updating the UI.
So in essence Zone.js tells Angular “something async happened, so some app state might have changed (maybe not though)”, Angular hears this, starts checking component data, and, if a change is found, updates the UI
Note: this is an oversimplification, in reality, Angular does some other stuff during change detection runs too, but those are out of the scope of this discussion.
So, change detection is a process completely detached from Zone.js. It is possible that we have used ChangeDetectorRef
to manually trigger change detection, like this:
import { Component, ChangeDetectorRef, inject } from '@angular/core';
@Component({
selector: 'app-root',
template: `
My App
Counter: {{ counter }}
`
})
export class AppComponent {
private readonly cdRef = inject(ChangeDetectorRef);
counter = 0;
increment() {
this.counter++;
this.cdRef.detectChanges();
}
}
Now if we go to the angular.json
file and remove zone.js
from the polyfills
array, and then add {ngZone: 'noop'}
to the bootstrapModule
options in main.ts
, our app will no longer ship Zone.js, but this component will continue to work as expected. Let’s inspect an excerpt from the Angular’s async
pipe’s source code:
@Pipe({
name: 'async',
pure: false,
standalone: true,
})
export class AsyncPipe implements OnDestroy, PipeTransform {
private _ref: ChangeDetectorRef|null;
private _latestValue: any = null;
private _obj: Subscribable<any>|Promise<any>|EventEmitter<any>
|null = null;
constructor(private ref: ChangeDetectorRef) { }
transform<T>(
obj: Observable<T>|Subscribable<T>|Promise<T>|null|undefined,
): T|null {
if (!this._obj) {
if (obj) {
this._subscribe(obj);
}
return this._latestValue;
}
if (obj !== this._obj) {
this._dispose();
return this.transform(obj);
}
return this._latestValue;
}
private _subscribe(
obj: Subscribable<any>|Promise<any>|EventEmitter<any>
): void {
// this method performs subscriptions to
// Observable or Promise
// and then calls _updateLatestValue
// omitted for brevity
}
private _updateLatestValue(async: any, value: Object): void {
if (async === this._obj) {
this._latestValue = value;
this._ref!.markForCheck();
}
}
}
So, as you may see, the transform
method actually doesn’t do much, it just subscribes (if not already subscribed) and then returns the latest value. The subscription is done by the _subscribe
method, which in its turn, well, subscribes, and calls _updateLatestValue
when a new value is emitted. And this is where the magic happens: the _updateLatestValue
method calls markForCheck
on ChangeDetectorRef
. So, the async
pipe is not using Zone.js at all and is manually triggering change detection.
Thus, if you had a hypothetical application that only ever uses RxJS Observable
-s with the async
pipe, you can kinda drop zone.js
from your app, and it will still work.
Note: the code example from the
async
pipe is incomplete and simplified, read more hereYet another note: Angular is adding Signals, so in the future, this might be a whole other kind of discussion
2. OnPush change detection works only when inputs are updated
Over the course of my own career, I have done multiple interviews for Angular developer positions, and approximately 90% of the candidates I interviewed had a misconception that the OnPush
change detection strategy works only when component inputs have been changed. Let’s first view a simple counterexample:
In this short example, the text
property of the ChildComponent
is set to “some text”, and when the button is clicked, it is updated to “other text”. The ChildComponent
is using the OnPush
change detection strategy, and has no inputs, but that does not prevent it from updating the UI when the button is clicked.
So what does ChangeDetectionStrategy.OnPush
actually do? What it does is
- First and foremost, it removes deep-checking on change detection, meaning if we pass inputs to a component with
OnPush
change detection, it will not readily update (it might update in the future). So to make sure updates to the UI are correct, we would need to change the reference to the input object. Let’s examine this in another example:
So, in here we have two components: AppComponent
and ChildComponent
, and the child one has an array as an Input
. Now, in the parent component, there are two buttons, one is called “Push to array from parent”, and the other one is “Change array reference”. Now clicking the first one (and only it) will not update the UI. Clicking the second one, however, will yield immediate results. Meaning with ChangeDetectionStrategy.OnPush
only referential equality is checked in the case of objects, and no deeper checks are being performed.
-
In the child component, the rules are a bit different, because if we click the “Push to array” button, located in the child component, we would get an immediate update – that is because with
OnPush
, local events trigger a change detection cycle. -
Changes are not gone – now this time we can try clicking the “Push to array from parent” button, maybe even several times, and then clicking the “Push to array” button in the child component. Now we will see an update with several items added to the array – that is because the changes are there, just have not been detected until we triggered them from the child component.
Of course, some other nuances are present, and you can read about more of them here. But as we have already seen, the OnPush
change detection strategy is not limited to inputs, and it is not limited to referential equality checks either.
3. Calling methods in templates is a crime
We have heard it a lot: DO NOT CALL METHOD IN ANGULAR TEMPLATES. But why should it be like this?
Well, the main reason is that because the value of the method (whatever it returns) is not readily available, Angular has to run call the function to extract the value and see if the UI needs to be updated, which in turn might result in a costly computation. Notice the wording here: costly computation. So what about not-so-costly computations? Consider these two examples:
@Component({
template: `
{{a + b}}
`,
})
export class AppComponent {
a = 1;
b = 2;
}
@Component({
template: `
{{sum()}}
`,
})
export class AppComponent {
a = 1;
b = 2;
sum() {
return this.a + this.b;
}
}
In both cases, we are going to get the same result, and the computation isn’t really that bad. As a matter of fact, in both cases, Angular is going to perform the same computation, and the only difference is that in the second case, Angular is going to call the sum
method, which will add some very negligible milliseconds of adding the function to the call stack and so on to the process.
Another interesting piece of Angular trivia: some built-in properties that exist on different Angular classes are, in fact, getters (thus functions), but we keep using them in templates without any issues. Here is a fun example: FormControl.valid
, which we might often use in a template, is actually a getter. (also all the other properties there are getters too), that just returns a simple computation result.
So what are the rules?
- Simply one rule – avoid costly operations, iterating over arrays, calling APIs, and so on. Everything else can be fair game.
Note: when signals are introduced and stable, the best approach would be to use a computed property to avoid all unnecessary function runs
4. Pipes should be pure
This is the continuation of the previous point: pure pipes (which all pipes are by default) will only calculate a new result when the input properties change. Impure pipes will run on every change detection cycle. But then again, the same argument can be made here: depends on the calculation. Sometimes we need impure pipes – especially when working with reactivity, and some built-in pipes are already impure, most famously the async
pipe. So, impure pipes are not a bad practice.
5. We can use an EventEmitter
as a Subject
EventEmitter
is well known in the Angular community – after all, it is one of the first ways of communicating between components that we learn. If we look at the source code, we can see that it extends RxJS Subject
, and if we try, we can see that we can not only call the emit
method on its instance but also subscribe to it and in general use it as an Observable
. But, in reality, we shouldn’t really do that. The problem is, the Angular team does not confirm that an EventEmitter
will always be an Observable
, so they might change this in the future. So, if you are using an EventEmitter
as a Subject
, you might be in for a surprise in the future. As a matter of fact, with signals being introduced, there is already an ongoing discussion of maybe changing the mechanism by which components can emit events.
6. We should always use Reactive Forms
Reactive Forms are pretty good – most of the functionality they provide is really helpful. But they come with a mental model that is harder than just using NgModel
– and that can slow down development in some cases. If anything, a better approach would be to use template-driven forms for simple forms, such as ones that do not really require complex validation and save Reactive forms for really heavy cases.
In Conclusion
Angular is a really rich ecosystem – and there are a lot of things that we can learn from it. But, as we have seen, there are lots of conflicting ideas out there – so in this article, I tried to sort some of them out and maybe clarify some things. If you know other things that Angular developers tend to misuse/misunderstand, feel free to share them in the comments below.