Change Detection in Angular - You Project Is 20x Slower!
Change detection is the most important and underrated topic in Angular. It you don't fully understand how it works your applications are 20 times slower than they might be. So let's fix that.
In this tutorial you will learn:
- When component renders?
- How does change detection work in Angular?
- ExpressionChangedAfterItHasBeenChecked error
- ChangeDetectionStrategy.OnPush
- When onPush component is rendered?
- Change detection with events
- Observables with OnPush
When component renders?
The first question that we should ask ourselves is when my component is rendered? And how can I see how many times it happens? In React for example you simply write console.log
inside component and every time when you see it it means your component is rendered again.
In Angular we can't do that because we have classes. The easiest way to check when you component is rendered is just by creating a function which returns something and call it in the template.
// app.component.ts
checkRender(): boolean {
console.log('checkRender');
return true;
}
// app.component.html
{{checkRender()}}
Here is a small application from 2 angular components that I prepared for us. We have an array of todos and a child component which renders each todo. Super simple code but already slow.
// todos.component.ts
@Component({
selector: 'app-todos',
templateUrl: './todos.component.html',
})
export class TodosComponent {
todos: TodoInterface[] = [
{
id: '1',
text: 'First todo',
isCompleted: true,
},
{
id: '2',
text: 'Second todo',
isCompleted: true,
},
{
id: '3',
text: 'Third todo',
isCompleted: false,
},
];
}
// todos.component.html
<app-todos-todo *ngFor="let todo of todos" [todo]="todo"></app-todos-todo>
// todo.component.ts
@Component({
selector: 'app-todos-todo',
templateUrl: './todo.component.html',
})
export class TodoComponent {
@Input('todo') todoProps: TodoInterface;
}
// todo.component.html
<div>
{{ todoProps.text }}
</div>
Let's right checkRender inside every todo and see how many times it happens.
// todo.component.ts
export class TodoComponent {
...
checkRender(): boolean {
console.log('checkRender')
return true
}
}
// todo.component.html
...
<div>Check render: {{ checkRender() }}</div>
As you can see in browser it happens 6 times. Now this is already not what we expected because we have just 3 todos in array.
How does change detection work in Angular?
Which brings us to the next question. How does change detection is working in Angular at all? So in any frontend framework we have a state of our application in Javascript and it's representation in DOM. After every change that we make like button click, Angular starts change detection so it goes from root component to the deepest child and check every single binding inside every component. So at every moment of the time there is a special View
inside Angular which represents each of our components. Angular stores inside this view all bindings to all variables that we have in template like old value and new value and compares them. If it sees that value differs it makes change in DOM tree.
ExpressionChangedAfterItHasBeenChecked error
Here is a super important point. This change detection cycle for all components is synchronous and it goes from top to bottom. But most importantly it does so twice to ensure that all bindings are not changed. This is important to make sure that in DOM tree we get correct values. And this second check is happened only in development mode. If you write Angular you probably so the most unclear error ExpressionChangedAfterItHasBeenChecked
. This error is being throws in only 1 case. If you bindings were changed between the first change detection and the second change detection. So for example if you use Time.now
in your template you will get such error because at the moment when Angular checks template second time the value of Time.now will be changed.
So this is why in development we see our changes twice and it is fine.
ChangeDetectionStrategy.OnPush
Let's add an input to our todos component. It doesn't have anything to do with our todos list and it just sets a value.
// todos.component.ts
export class TodosComponent {
...
changeText(): void {
console.log('changeText');
}
}
// todos.component.html
<div><input type="text" (keyup)="changeText()" /></div>
But if we try to type something inside our input you can see that our todo component is rerendered every single time when we type a letter. This is what you have in your application by default and this is why your application is probably 20 times slower that it should be.
Why is it happening? Because with every single change or DOM event Angular makes the whole change detection cycle again. How we can fix this? By changing your change strategy of the component to onPush
@Component({
selector: 'app-todos-todo',
templateUrl: './todo.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
What is does it excludes this component from change detection completely and it will trigger the render of this component only if specific cases.
As you can see in browser, now we don't render our todos components when we change input which is the correct behaviour.
When onPush component is rendered?
Now the question is when our component with OnPush will be rerendered? The most popular case is when our inputs will be changed. In our case if todo is changed than component is rendered again which sounds correct. But here is another problem. If we simply change the value of todo outside it won't work.
// todos.component.html
<button (click)="changeArray()">Change array<button></button></button>
// todos.component.ts
changeArray(): void {
this.todos[0].text = 'Foo';
console.log('qq', this.todos);
}
So we have a button to change a property inside our array. But as you can see there is no rendering of todo again. If we comment OnPush strategy it will be working because Angular checks component and every single binding inside. But actually the problem is in the way how Angular compares input. It just makes normal Javascript compare and arrays and objects are equal if they reference the same data as previously. This is why we didn't change the input because we just modified the property of object and the reference to the object stays the same. So to really trigger the input change we must change the object.
changeArray(): void {
this.todos[0] = { ...this.todos[0], text: 'Foo' };
console.log('qq', this.todos);
}
So we used spread operator here and put inside completely new object. And as you can see it works correctly and component is changed.
Change detection with events
But now you might say okay but what is we have a DOM event inside the component. Will it work with OnPush or will it be ignored?
// todo.component.ts
@Component({
selector: 'app-todos-todo',
templateUrl: './todo.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoComponent {
...
someLocalProp = 'Some local prop';
...
changeSomeLocalProp(): void {
this.someLocalProp = 'Changed some local prop';
}
}
// todo.component.html
<div>{{ someLocalProp }}</div>
<button (click)="changeSomeLocalProp()">Change some local prop</button>
As you can see in browser everything works perfectly which means we can use events inside components with OnPush and render will be triggered.
Observables with OnPush
But in Angular world we use streams a lot and the question is OnPush working with streams or not? Here I have a service with stream inside so let's try to render it in our Todo component.
@Injectable()
export class TodosService {
filter$ = new BehaviorSubject('all');
}
filter$: Observable<string>;
constructor(private todosService: TodosService) {
this.filter$ = todosService.filter$;
}
changeFilter(): void {
this.todosService.filter$.next('active');
}
<div>
Filter: {{ filter$ | async }}
<button (click)="changeFilter()">Change filter</button>
</div>
So here we rendered our filter change created a button to change it in service. As you can see our component is rendered when our stream changes and you probably thought it should not because it's an object and we reference the same object every time. You are totally right but it's Angular async pipe which triggers rendering of the component when new value comes in stream because Angular knows that component must be rendered.
Conclusion
So here is the conclusion. OnPush and manual controlling when your component should render is a must if you want to build fast Angular applications.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!