Useful Development

Angular reactive forms: FormArrays with OnPush change detection

posted on Jun 27, 2019

Large reactive forms can be painful to work with unless you break them down into smaller components. Using OnPush change detection with those components can improve performance. Doing these two things together can lead to some unexpected behaviour around FormArrays which, judging by the comments on Stack Overflow at least leads many developers to give up and switch the change detection back to default. I've created a StackBlitz sample to show a way to get round these issues (see below).

Overview

The sample has a simple form consisting of a single FormArray called things. The user can disable/enable the form, add a new thing, load up the form array with existing values and remove an existing value. Child components have been created for the FormArray (ThingsComponent) and the 'thing' FormGroup (ThingComponent). Both of the components have the change detection strategy set to OnPush. Here are the key points regarding how to get everything working:

Make the components responsible for creating the FormGroup

This isn't essential but keeping the definition for the FormGroup in one place, namely the FormGroup's component avoids errors that can occur typing out the definition in multiple places. This is done by exporting a function that creates the FormGroup and exporting it from the component.

thing.component.ts
 
export function ThingFormGroup(formBuilder: FormBuilder){
  return formBuilder.group({
    id:[],
    name:[]
  });
}

The component containing the FormArray should listen for value changes

The ThingsComponent is responsible for display ingthe Thing components. This is the first potential stumbling block for OnPush. As the property itself is not updated on a change to the FormArray the component needs to listen for values changes to the FormArray and manually call change detection. To do this in a way where the component itself handles everything without the consumer having to worry about it the ThingsComponent uses a getter and setter on the formArray to subscribe/unsubscribe form the formArray valueChanges.

things.component.ts
 
export class ThingsComponent implements OnDestroy {
  private _formArray: FormArray;
  @Input()
  set formArray(formArray: FormArray) {
    this._formArray = formArray;

    if (this.subscriptions.formArrayChanges) {
      this.subscriptions.formArrayChanges.unsubscribe();
    }

    this.subscriptions.formArrayChanges = this.formArray.valueChanges.subscribe(
      result => {
        this.changeDetectorRef.detectChanges();
      }
    );
  }
  get formArray() {
    return this._formArray;
  }

  private subscriptions: { [key: string]: Subscription } = {};

  constructor(
    private changeDetectorRef: ChangeDetectorRef,
    private formBuilder: FormBuilder
  ) { }

  ngOnDestroy() {
    Object.keys(this.subscriptions).forEach(sk =>
      this.subscriptions[sk].unsubscribe()
    );
  }

Another benefit of having the formArray as a property is that is avoids getting an error when trying to implicitly convert from AbstractControl when compiling with AOT.

Loading values into the FormArray

This is another pain point with FormArrays, in order to add values to the form it is necessary to add the FormGroups to the FormArray first, then patch the value.

app.component.ts
 
 load() {
    const things = [{
      id: 1,
      name: 'one'
    }, {
      id: 2,
      name: 'two'
    }, {
      id: 3,
      name: 'three'
    }];

    things.forEach(thing => {
      (this.formGroup.get('things') as FormArray).controls.push(
        ThingFormGroup(this.formBuilder)
      );
    });

    this.formGroup.patchValue({
      things
    });
  }

Removing an item from the FormArray

The ThingComponent has a button which raises an event called remove with the formGroup as the event argument when it is clicked.

thing.component.ts
 
export class ThingComponent {
  @Output() remove = new EventEmitter();
  @Input() formGroup: FormGroup;

  constructor() {}
}

thing.component.html
 
<mat-card [formGroup]="formGroup" fxLayout="row wrap" fxLayoutAlign="start center" fxLayoutGap="12px">
    <mat-form-field fxFlex="1 1 auto">
        <input matInput placeholder="id" formControlName="id" />
    </mat-form-field>
  <mat-form-field fxFlex="1 1 auto">
    <input matInput placeholder="name" formControlName="name" />
  </mat-form-field>
  <button
    fxFlex="0 0 auto"
    mat-mini-fab
    color="warn"
    [disabled]="formGroup.disabled"
    (click)="remove.emit(formGroup)"
  >X</button>
</mat-card>

The ThingsComponent listens for this event and removes the formGroup from the form array. The ThingComponent has a button which raises an event called remove with the formGroup as the event argument when it is clicked.

things.component.html
 
	<app-thing fxFlex="1 1 auto" *ngFor="let thingFormGroup of formArray.controls; let i = index" (remove)="remove($event)"
	 [formGroup]="thingFormGroup">
	</app-thing>

things.component.ts
 
remove(formGroup: FormGroup) {
    this.formArray.removeAt(this.formArray.controls.indexOf(formGroup));
  }

Making the formArray component reusable

If this is approach is looking like a bit of a pain to do multiple times throughout a big project there is some good news. With only a couple of changes the ThingsComponent can be made into a generic component that can be used for all FormArrays. For the final code check out this fork of the original sample. The ThingsCompnent has been renamed to FormArrayComponent. The add function was specific to the ThingComponent so that has been changed to just being an output event so the parent component can handle adding any type of FormGroup required.

form-array.component.ts
 
export class FormArrayComponent implements OnDestroy {
  @Output() add = new EventEmitter();
}

Also instead of having the ThingComponent declared in the template ang-content tag is ued so that the child component for the FormGroup can be transcluded into it.

form-array.component.html
 
 <mat-card class="form-array" fxLayout="column" fxLayoutGap="12px">
  <ng-content></ng-content>
  <button
    fxFlexAlign="end"
    mat-raised-button
    [disabled]="formArray.disabled"
    (click)="add.emit(formArray)"
    color="primary"
  >
    add
  </button>
</mat-card>

Lastly form has been changed to use the generic form array component.

 
   <form [formGroup]="formGroup">
       <app-form-array
          [formArray]="formGroup.get('things')"
          (add)="add($event)"
          #things>
          <app-thing
            fxFlex="1 1 auto"
            *ngFor="
              let thingFormGroup of things.formArray.controls;
              let i = index
            "
            (remove)="things.remove($event)"
            [formGroup]="thingFormGroup"
        ></app-thing>
    </app-form-array>  
</form>

angularangular-reactive-forms