Search code examples
angularformarray

Angular Reactive Form Array, Change detection overriding form


Ive used the following question as a basis for my form design for a FormArray. The jist of what im trying to do is keep a form up to date with changes elsewhere on the page, but toggle a boolean/checkbox in this form. (Users have a list of cards they select, this form shows a list of this selection)

Unfortunately, it seems that ngOnChanges is constantly updating the form, and my changes are being overwritten. In my constructor I detect value changes, with an intent to emit those changes. However,

this.contextSummaryForm.dirty

is always false. A breakpoint on rebuildForm() demonstrates that the method is invoked multiple times a second - thus changing contextItem.isEditEnable to false is completely overwritten. I can read my logic and see why that is happening sort of - but I seriously don't understand what I'm supposed to do to allow updates to contextList from its parent component AND allow users to update the form here.

Constructor, and change detection

@Input()
contextList: ContextItem[];

@Output()
contextListChange  = new EventEmitter<any>();

valueChangeSubscription = new Subscription;
contextSummaryForm: FormGroup;
isLoaded: boolean = false;


constructor(protected fb: FormBuilder) {
   this.createForm();


   this.valueChangeSubscription.add(
         this.contextSummaryForm.valueChanges
         .debounceTime(environment.debounceTime)
           .subscribe((values) => {
           if (this.isLoaded && this.contextSummaryForm.valid && this.contextSummaryForm.dirty) {

             this.contextSummaryForm.value.plans.forEach(x => {
               var item = this.contextList.find(y => y.plan.id === x.id);
               item.isEditEnabled = x.isEditEnabled;
             });

             this.contextListChange.emit(this.contextList);
             this.contextSummaryForm.markAsPristine();
           }
         }));
     }

Form Creation:

createForm(): void {
 this.contextSummaryForm = this.fb.group({
  plans: this.fb.array([this.initArrayRows()])
 });
}

initArrayRows(): FormGroup {
  return this.fb.group({
   id: [''],
   name: [''],
   isEditEnabled: [''],
});
}

OnChanges

  ngOnChanges(changes: SimpleChanges) {
for (let propName in changes) {
  if (propName === 'contextList') {
    if (this.contextList) {
      this.rebuildForm();
      this.isLoaded = true;
    }
  }
}
}

rebuildForm() {
  this.contextSummaryForm.reset({
  });
  //this.fillInPlans();
  this.setPlans(this.contextList);
}


  setPlans(items: ContextItem[]) {
    let control = this.fb.array([]);
    items.forEach(x => {
      control.push(this.fb.group({
        id: x.plan.id,
        name: x.plan.Name,
        isEditEnabled: x.isEditEnabled,
      }));
    });
    this.contextSummaryForm.setControl('plans', control);
  }

Just to summarize: i need a way to use formarrays built from an input binding that keeps up with changes without rapidly overwriting the form.


Solution

  • According to angular's documentation

    OnChanges: A lifecycle hook that is called when any data-bound property of a directive changes

    Having said that, it's not the change detection nor the onChanges hook that overrides the form. As the best practice, we should build the form only once and use the FormArray's methods to interfere with the Array

    Instead of rebuilding the form, you can utilize the FormArray methods and push items directly on the array. I assume that the problem you are facing is that you are rebuilding the form, no matter the data are.

    What I mean is: Think that you have two components. Child and Parent. The child is responsible to manipulate the data (add, remove), and the parent is responsible to display on a form those data. Although it seems to be trivial to do so, you have to filter out any items that have already been processed by Child component.

    In your implementation, try to not to rebuild the form on the onChanges but rather push items on the array.

    Which items should you push to array? (filter out any items that have already been processed)

    const contextItemsToInsert = 
              this.contextList.filter((it: any) => !this.plans.value.map(pl => pl.id).includes(it.id));
    

    This is an approach that could solve your problem

    ngOnChanges(changes: SimpleChanges) {
        for (let propName in changes) {
          if (propName === 'contextList') {
    
            const contextItemsToInsert = 
              this.contextList.filter((it: any) => !this.plans.value.map(pl => pl.id).includes(it.id));
    
                contextItemsToInsert.forEach((x: any) => {
    
                  this.plans.push(this.fb.group({
                    id: x.id,
                    name: x.name,
                    isEditEnabled: x.isEditEnabled,
                  }))
                })
    
                // similar approach for deleted items
          }
        }
      }
    
      ngOnInit() {
        this.createForm();
      }
    
      createForm(): void {
        this.contextSummaryForm = this.fb.group({
          plans: this.fb.array([this.initArrayRows()])
        });
      }
    
      initArrayRows(): FormGroup {
          return this.fb.group({
            id: [''],
            name: [''],
            isEditEnabled: ['']
        });
      }
    

    Here you can find a working example https://stackblitz.com/edit/stackoverflow-53836673

    It's not a fully working example, but it helps get the point of the issue.

    I hope I understood correctly what the problem you are facing is