I created a demo to show how a list can undergo extra rendering after a button click occurs in a list item. Rather than re-rendering one list item in the change detection cycle, all the list items are rendered. When the list is short, performance is not slow. However, when the list is long, for example, 1000 items, it suffers a performance hit.
After explaining the cause of the extra rendering, I propose a fix such that the change detection cycle updates the affected list item component and not all.
App Component
export type Favorite = {
id: number;
content: string;
}
function generateFavorite(n: number): Favorite[] {
return [...Array(n).keys()].map((n) => ({
id: n,
content: `Favorite Content ${n}`,
}));
}
export const favorites: Favorite[] = generateFavorite(10);
favorites
is a list of 10 favorite items. Favorite
is a simple type that stores the ID and text of the item.
@Component({
selector: 'app-root',
imports: [FavoriteList],
template: `
<div class="container">
<app-favorite-list [items]="favorites()" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
favorites = signal<Favorite[]>(favorites);
}
The App
component passes the favorites array to the FavoriteList component to display the list.
Implementation of FavoriteList Component
@Component({
selector: 'app-favorite-list',
imports: [FavoriteItem],
template: `
<div>
Favorite List: {{ favoriteList().join(',') }}
</div>
@for (item of items(); track item.id) {
<app-favorite-item [item]="item" (updateFavorites)="updateFavoriteList($event)" [favoriteList]="favoriteList()" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FavoriteList {
items = input<Favorite[]>([]);
favoriteList = signal<number[]>([]);
updateFavoriteList(id: number) {
const isRemoved = this.favoriteList().includes(id);
if (isRemoved) {
this.favoriteList.update((prev) => prev.filter((item) => item !== id));
} else {
this.favoriteList.update((prev) => [...prev, id]);
}
}
}
The FavoriteList
component has an items
input signal and a favoriteList
signal that stores the IDs of the favorite items. When the FavoriteItemComponent
component emits the updateFavorites
custom event, the updateFavoriteList
method creates a new array and sets the value to the favoriteList
signal. Then, the favoriteList
signal is passed to the FavoriteItem
component to change the background color.
Implementation of FavoriteItem Component
<div class="favorite" [style.backgroundColor]="favoriteBgColor()">
@let f = favorite();
<h3>Favorite Item: {{ f.id }}</h3>
<p>Content: {{ f.content }}<p>
<div>
@let buttonText = isFavorite() ? 'Unfavorite' : 'Favorite';
<button (click)="updateFavorites.emit(f.id)">{{ buttonText }}</button>
</div>
</div>
{{ printItem() }}
@Component({
selector: 'app-favorite-item',
templateUrl: './favorite-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FavoriteItem {
favorite = input.required<Favorite>({ alias: 'item' });
favoriteList = input<number[]>([]);
updateFavorites = output<number>();
isFavorite = computed(() => this.favoriteList().includes(this.favorite().id));
favoriteBgColor = computed(() => this.isFavorite() ? 'yellow' : 'transparent');
printItem() {
console.log(`Favorite item ${this.favorite().id} rendered`);
}
}
The component's printItem
method is executed in the template to measure how frequently it is re-rendered. When users click the button, the component emits the ID in the updateFavorites
custom event.
Moreover, the component has two computed signals. The isFavorite
computed signal determines whether the item is a favorite. The favoriteBgColor
computed signal determines the background color. When the item is a favorite, the background color is yellow. Otherwise, it is transparent.
Users need to open the Chrome DevTool console to observe the console logs. When users click a button, the console shows ten console logs, not one. If the list has 1000 items, the console will show 1000 logs, which is not good.
Why did it happen?
Change detection occurs in a component when one of these things happens:
- An event listener executed
- New input
- AsyncPipe resolved in the template
- Signals are updated
- ChangeDetectionRef.markForCheck is fired
Numbers 1, 3, 4, and 5 did not happen to the other nine list items. When the FavoriteList
component sets a new reference to the favoriteList
signal, the FavoriteItem
component receives a new input. Therefore, the change detection cycle renders all the FavoriteItem
components.
Fortunately, it can be fixed by adding a isFavorite
property to the Favorite
type and changing the computed and input signals.
The fix
export type Favorite = {
id: number;
content: string;
isFavorite: boolean;
}
The Favorite
type has a new isFavorite
property to maintain the favorite status.
function generateFavorite(n: number): Favorite[] {
return [...Array(n).keys()].map((n) => ({
id: n,
content: `Favorite Content ${n}`,
isFavorite: false,
}));
}
The function initializes the isFavorite
property to false.
@Component({
selector: 'app-favorite-list',
imports: [FavoriteItem],
template: `
<div>
Favorite List: {{ favoriteList().join(',') }}
</div>
@for (item of items(); track item.id) {
<app-favorite-item [item]="item" (updateFavorites)="updateFavorite.emit($event)" />
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FavoriteList {
items = input<Favorite[]>([]);
updateFavorite = output<number>();
favoriteList = computed(() => this.items().reduce((acc, item) => {
if (item.isFavorite) {
return acc.concat(item.id);
}
return acc;
}, [] as number[]));
}
The FavoriteList
component changes the favoriteList
signal to a computed signal. The computed signal iterates the items
input signal to derive the favorite list and display in the template. Moreover, it has a new updateFavorite
custom event to emit the favorite ID to the App
component. Then, the App
component updates the isFavorite
property of the items in the array.
@Component({
selector: 'app-root',
imports: [FavoriteList],
template: `
<div class="container">
<app-favorite-list [items]="favorites()" (updateFavorite)="updateFavoriteList($event)" />
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
favorites = signal<Favorite[]>(favorites);
updateFavoriteList(id: number) {
this.favorites.update((items) => {
return items.map((item) => {
if (item.id === id) {
return {
...item,
isFavorite: !item.isFavorite,
}
}
return item;
});
})
}
}
The FavoriteList
component emits the ID in the updateFavorite
custom event. The App component invokes the updateFavorites
method to construct a new list and overwrite the favorites
signal. The FavoriteList
component receives a new input and is re-rendered as expected. Furthermore, only one item has a new reference in the favorites
signal, and the other nine items do not.
@Component({
selector: 'app-favorite-item',
templateUrl: './favorite-item.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FavoriteItem {
favorite = input.required<Favorite>({ alias: 'item' });
updateFavorites = output<number>();
favoriteBgColor = computed(() => this.favorite().isFavorite ? 'yellow' : 'transparent');
printItem() {
console.log(`Favorite item ${this.favorite().id} rendered`);
}
}
In the FavoriteItem
component, the favoriteList
input is removed. The favoriteBgColor
uses the favorite
input signal to determine the background color. After making the updates, the printItem
method prints one console log after clicking the button. Because only one item reference is updated in the array after each click, the change detection cycle only re-renders one list item.
With a few code changes, the number of re-rendering reduces from ten to one, and it is a very useful performance optimization technique to apply to a real-world Angular project.
References:
- Stackblitz Demo:
- Poor performance list: https://stackblitz.com/edit/stackblitz-starters-uyh5ylyv?file=src%2Ffavorite-list.component.ts
- Improved list: https://stackblitz.com/edit/stackblitz-starters-wlvbat2k?file=src%2Fmain.ts
Top comments (0)