DEV Community

Connie Leung
Connie Leung

Posted on

Performance tip - How to avoid too many re-rendering items in an Angular List

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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]);
   }
 }
}
Enter fullscreen mode Exit fullscreen mode

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() }}
Enter fullscreen mode Exit fullscreen mode
@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`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. An event listener executed
  2. New input
  3. AsyncPipe resolved in the template
  4. Signals are updated
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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,
 }));
}
Enter fullscreen mode Exit fullscreen mode

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[]));
}
Enter fullscreen mode Exit fullscreen mode

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;
     });
   })
 }
}
Enter fullscreen mode Exit fullscreen mode

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`);
 }
}
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)