htmlangulartypescriptfor-loopngfor

Are there differences in how angular's @for and ngFor handle change detection?


I've used ngFor for many years now, but have been interested in transitioning over to the newer @for as it seems to make the code a little cleaner (in my opinion). However recently I just discovered an interesting phenomenon that I'm not sure if it's a "feature" of the @for loop that I just don't understand how it's intended to work, or if maybe I've uncovered a bug.

The scenario I've come across is where I'm entering a list of form entries that are changing while the user is typing. The ngFor scenario seems to detect and rerender the changes perfectly, but the @for scenario seems to get confused whether there are changes and in some scenarios will rerender correctly and other scenarios will not rerender correctly.

Here's the .ts file

// @for
testingWithAtForNames: { name: string }[] = [{ name: '' }]
onTestingWithAtForChanged() {
    this.testingWithAtForNames = [
        ...this.testingWithAtForNames.filter(x => x.name.length),  // remove entries with zero length
        { name: '' }                                               // add empty entry to bottom
    ]
}

// ngFor
testingWithNgForNames: { name: string }[] = [{ name: '' }]
onTestingWithNgForChanged() {
    this.testingWithNgForNames = [
        ...this.testingWithNgForNames.filter(x => x.name.length),  // remove entries with zero length
        { name: '' }                                               // add empty entry to bottom
    ]
}

And here's the .html file

<!-- @for -->
<div style="margin: 10px; padding: 5px; border: 2px solid black; display: flex; flex-direction: column;">
    <div> testing with &commat;for </div>
    @for (testingWithAtForName of testingWithAtForNames; track $index) {
        <input 
            [(ngModel)]="testingWithAtForName.name"
            (ngModelChange)="onTestingWithAtForChanged()">
    }
</div>

<!-- ngFor -->
<div style="margin: 10px; padding: 5px; border: 2px solid black; display: flex; flex-direction: column;">
    <div> testing with Ngfor </div>
    <input 
        *ngFor="let testingWithNgForName of testingWithNgForNames" 
        [(ngModel)]="testingWithNgForName.name" 
        (ngModelChange)="onTestingWithNgForChanged()">
</div>

Here's what is expected:

Here's what happens in the ngFor scenario (this code seems to do a good job with change detections)

  1. Form 1 is empty
    • ""
  2. User types a letter "a" into form 1 causing an empty form 2 to appear
    • "a"
    • ""
  3. User types a letter "a" into form 2 causing an empty form 3 to appear
    • "a"
    • "a"
    • ""
  4. User deletes the letter "a" from form 1 causing form 1 to be removed and everything left to "move up"
    • "a"
    • ""
  5. Notice how there is always one blank form box at the bottom and never any blank form boxes in the middle

Here's what happens in the @for scenario (this code seems broken and doesn't seem to detect changes as well as ngFor)

  1. Form 1 is empty
    • ""
  2. User types a letter "a" into form 1 causing an empty form 2 to appear
    • "a"
    • ""
  3. User types a letter "a" into form 2 causing an empty form 3 to appear
    • "a"
    • "a"
    • ""
  4. User deletes the letter "a" from form 1, but instead of form 1 being removed and everything else simply "moving up" the result is that there are now incorrectly 2 empty forms
    • ""
    • ""
  5. When I print the contents of the variable testingWithAtForNames to the console then I see that there are correctly 2 entries and the first one has an "a" and the second one is empty, but angular didn't render it correctly, or it didn't detect the changes correctly.

This scenario seems to happen when the text that is entered is the SAME in the different forms. So like in the above examples I was just using the letter "a" in all of the forms. If I used the text "asdf" in all of the forms then similarly the @for scenario will still break. However, if I entered something different into each form then the code works as expected. So like If I wrote "qwer" in the first form, then "asdf" in the second form, then when I go to delete the "qwer" text then it will correctly remove "qwer" from the display and correctly show the remaining "asdf" and "" two entries.

So I guess in closing, I'm trying to figure out if there is something I'm missing regarding how I'm supposed to correctly use the @for loop in angular. Clearly there is something different between @for and ngFor, but I'm not sure if it's a bug that I should report or if there was some sort of intended change in how @for does change detection that I'm missing.

Any help would be greatly appreciated.


Solution

  • The only difference is the track which is extra (but mandatory) for @for compared to *ngFor.

    Without the trackBy specified for *ngFor, for every change detection cycle the entire list gets re-rendered. Which does not cause this bug, but it is not effecient either.


    That said, because we are shifting the element indexes, I think track $index is messing up the UI and [(ngModel)] bindings.

    The solution, can be to track the index by each item. Which does not seem to have this bug.

    <div style="margin: 10px; padding: 5px; border: 2px solid black; display: flex; flex-direction: column;">
      <div> testing with &commat;for </div>
      @for (testingWithAtForName of testingWithAtForNames; track testingWithAtForName) {
          <input 
              [(ngModel)]="testingWithAtForName.name"
              (ngModelChange)="onTestingWithAtForChanged()">
      }
    </div>
    

    Full Code:

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { CommonModule } from '@angular/common';
    import { FormsModule } from '@angular/forms';
    
    @Component({
      selector: 'app-root',
      imports: [CommonModule, FormsModule],
      template: `
        <!-- @for -->
        <div style="margin: 10px; padding: 5px; border: 2px solid black; display: flex; flex-direction: column;">
          <div> testing with &commat;for </div>
          @for (testingWithAtForName of testingWithAtForNames; track testingWithAtForName) {
              <input 
                  [(ngModel)]="testingWithAtForName.name"
                  (ngModelChange)="onTestingWithAtForChanged()">
          }
        </div>
    
        <!-- ngFor -->
        <div style="margin: 10px; padding: 5px; border: 2px solid black; display: flex; flex-direction: column;">
            <div> testing with Ngfor </div>
            <input 
                *ngFor="let testingWithNgForName of testingWithNgForNames" 
                [(ngModel)]="testingWithNgForName.name" 
                (ngModelChange)="onTestingWithNgForChanged()">
        </div>
      `,
    })
    export class App {
      name = 'Angular';
      testingWithAtForNames: { name: string }[] = [{ name: '' }];
      testingWithNgForNames: { name: string }[] = [{ name: '' }];
      // @for
      onTestingWithAtForChanged() {
        this.testingWithAtForNames = [
          ...this.testingWithAtForNames.filter((x) => x.name.length), // remove entries with zero length
          { name: '' }, // add empty entry to bottom
        ];
      }
    
      // ngFor
      onTestingWithNgForChanged() {
        this.testingWithNgForNames = [
          ...this.testingWithNgForNames.filter((x) => x.name.length), // remove entries with zero length
          { name: '' }, // add empty entry to bottom
        ];
      }
    }
    
    bootstrapApplication(App);
    
    

    Stackblitz Demo