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 @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)
Here's what happens in the @for scenario (this code seems broken and doesn't seem to detect changes as well as ngFor)
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.
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 @for </div>
@for (testingWithAtForName of testingWithAtForNames; track testingWithAtForName) {
<input
[(ngModel)]="testingWithAtForName.name"
(ngModelChange)="onTestingWithAtForChanged()">
}
</div>
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 @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);