Rendering Lists in Angular with @for and track

Angular Tutorial: Beginner to Advanced · 4. Control flow and lists · 4 min read · Updated

Almost every app shows lists: tasks, products, messages, search results. The @for block repeats a piece of template for each item in a collection.

What you will learn

  • How to loop over an array with @for
  • Why track is required and what to put in it
  • How to show a message for an empty list
  • How to use $index, $first, $last and $count

The basic loop

<ul>
  @for (task of tasks(); track task.id) {
    <li>{{ task.title }}</li>
  }
</ul>

Read it as "for each task in tasks(), draw this <li>". The part after track is explained next.

Why track?

When the list changes, Angular wants to reuse the existing elements instead of destroying and rebuilding all of them. track tells it how to recognise each item. The best choice is a value that is unique and stays the same, such as an id. If your items are plain strings or numbers, you can track the value itself with track $index or track item.

Choosing a stable key keeps scroll positions, typed text in inputs and animations in the right place when items are added, removed or reordered.

Empty lists

Add an @empty block right after the loop. It is shown when there are no items:

@for (task of tasks(); track task.id) {
  <li>{{ task.title }}</li>
} @empty {
  <li>Nothing to do. Add your first task!</li>
}

Built-in variables

Inside the loop you can use these automatically provided values:

  • $index: the position of the item, starting at 0
  • $count: the total number of items
  • $first and $last: true for the first and last item
  • $even and $odd: true for alternating items
@for (task of tasks(); track task.id; let i = $index) {
  <li>{{ i + 1 }}. {{ task.title }}</li>
}

The let i = $index part gives the built-in value a shorter name.

A task list you can use

This component lets you add and remove tasks. It also introduces a template reference variable, #titleInput, which gives the template direct access to an element:

// src/app/task-list/task-list.ts
import { Component, signal } from '@angular/core';
import { Task } from '../task.model';

@Component({
  selector: 'app-task-list',
  template: `
    <h2>My tasks ({{ tasks().length }})</h2>

    <input #titleInput placeholder="New task" />
    <button type="button" (click)="add(titleInput.value); titleInput.value = ''">Add</button>

    <ul>
      @for (task of tasks(); track task.id; let i = $index) {
        <li>
          {{ i + 1 }}. {{ task.title }}
          <button type="button" (click)="remove(task.id)" [attr.aria-label]="'Remove ' + task.title">Remove</button>
        </li>
      } @empty {
        <li>Nothing to do. Add your first task!</li>
      }
    </ul>
  `,
})
export class TaskList {
  private nextId = 1;
  protected readonly tasks = signal<Task[]>([]);

  protected add(title: string): void {
    const clean = title.trim();
    if (!clean) {
      return;
    }
    this.tasks.update((list) => [...list, { id: this.nextId++, title: clean, done: false }]);
  }

  protected remove(id: number): void {
    this.tasks.update((list) => list.filter((task) => task.id !== id));
  }
}
// src/app/app.ts
import { Component } from '@angular/core';
import { TaskList } from './task-list/task-list';

@Component({
  imports: [TaskList],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <h1>Lists</h1>
  <app-task-list />
</main>

Add a few tasks and remove one from the middle. Each removal keeps the other rows stable because every task has its own id to track.

The add method ignores empty text. Checking input before you use it is a good habit, and you will do it more thoroughly with forms later.

Looping over other collections

@for works with arrays and other iterable values. To loop over the properties of an object, convert it first, for example with Object.entries(obj) in your class.

Common mistakes

  • Leaving out `track`: the compiler reports an error. Always provide it.
  • Tracking `$index` for lists that change order: Angular then cannot tell that an item moved. Prefer a unique id.
  • Changing the array in place: use update with a new array so the signal notices the change.

Try it yourself

  1. Add a "Clear all" button that sets the list to an empty array.
  2. Show "(last)" next to the final task using $last.
  3. Give every task a done flag, and add a button that toggles it by creating a new array with map.

Quick check

  1. What does track do, and what is a good value for it?
  2. Which block displays a message for an empty list?
  3. How do you get the position of an item inside the loop?

Next, continue with Angular ngIf, ngFor, ngSwitch and trackBy: Legacy Syntax and Migration.

Frequently asked questions

What replaced *ngFor?

The @for block. It reads more like normal code, and track is required, which avoids a common source of slow lists.

Can I use @if inside @for?

Yes. Blocks can be nested as deeply as you need, for example to show a badge only for completed tasks.