Search code examples
angularangular-reactive-formsangular-forms

@for behaves oddly with forms on removeAt(index)


EDIT: Improved the stackblitz a bit, so people can compare it side by side. enter image description here

With Angular 17+ I wanted to migrate my project also with the new template syntax, which appears to have an odd behavior and I haven't seen anyone talking about it. Delete items with removeAt in the old *ngFor loop appears to work plenty fine. However, when I do attempt the same within a @for ... irrespective if the index is actually accurate (you can console log it, it IS indeed pointing each time to the correct controls) but with the removeAt it simply keep deleting the last item in the array. Am I misunderstanding @for? I added a minimal recreation of my problem as a stackblitz at the end of this post

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import {
  FormArray,
  ReactiveFormsModule,
  UntypedFormBuilder,
} from '@angular/forms';
import 'zone.js';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-root',
  imports: [ReactiveFormsModule, CommonModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  template: `
    <form [formGroup]="baseSearchForm">

      <div>
        <input type="text" formControlName="search" placeholder="Search for ...">
      </div>

      <div style="margin-top: 1rem; background: lightgray; padding: 1rem; " formArrayName="searchParams">

       <!-- removeFilter here will always successfully delete the right control -->
        <!-- <div *ngFor="let control of searchParams.controls; let i = index;">
          <div style="display: flex; flex-direction: row;" [formGroupName]="i">
            <input type="text" formControlName="field" placeholder="... where column ...">
            <input type="text" formControlName="operator" placeholder="... is ...">
            <button *ngIf="i > 0" (click)="removeFilter(i)">Remove</button> 
          </div>
        </div> -->

        <!-- removeFilter will only delete the last element of the array, regardless of the index -->
        @for(control of searchParams.controls; track $index;) {
          <div style="display: flex; flex-direction: row;" [formGroupName]="$index">
            <input type="text" formControlName="field" placeholder="... where column ...">
            <input type="text" formControlName="operator" placeholder="... is ...">
            <button *ngIf="$index > 0" (click)="removeFilter($index)">Remove</button> 
          </div>
        }
      </div>
      <button style="margin-top: 1rem" (click)="addFilter()">Add Filter</button>
  </form>
  `,
})
export class App {
  fb = inject(UntypedFormBuilder);
  baseSearchForm = this.fb.group({
    search: [''],
    searchParams: this.fb.array([
      this.fb.group({
        field: [0],
        logicalOperator: [''],
        operator: [''],
        type: [''],
        values: [],
      }),
    ]),
  });

  get searchParams() {
    return this.baseSearchForm.get('searchParams') as FormArray;
  }

  removeFilter(index: number) {
    this.searchParams.removeAt(index);
  }

  addFilter() {
    this.searchParams.push(
      this.fb.group({
        field: [this.searchParams.length],
        logicalOperator: [''],
        operator: [''],
        type: [''],
        values: [],
      })
    );
  }
}

bootstrapApplication(App);

Stackblitz


Solution

  • I'll give the same answer I gave on the GitHub issue:

    This is one of the cases where the dom is stateful and you shouldn't use the index as a tracking key.

    The ngFor directive uses the indentity as a tracking key by default, you can do the same here by tracking the control:

    @for(control of searchParamsAlt.controls; track control)