Search code examples
angularangular-reactive-formsangular-components

How to solve a change detection issue with nested reactive form components?


I have the following (simplified) scenario:

  • A component that has a (reactive) form with nested components acting as form controls
  • One of the nested components is called SelectComponent that offers a <select> tag
  • The SelectComponent implements NG_VALUE_ACCESSOR and NG_VALIDATORS
  • The parent component supplies the select options as input field to the SelectComponent

This works well so far, but I have issues when I want to automatically update the behavior in the inner SelectComponent based on the following:

  • If there are multiple options or if the control is not required, do nothing
  • If there is only one option and if the control is required, automatically select the (only) option.

I implemented the ngOnChanges event in order to achieve the above.

Here is a minimal example on Stackblitz that is nearly working: https://stackblitz.com/edit/angular-ohdb71?file=src%2Fapp%2Fselect%2Fselect.component.ts

But then, I have problems with change detection in the main component. As you can see, the main component does not update the user.value to Walter automatically, even though the inner SelectComponent has set this value and also announced it to the outer component. Consequently, the main component thinks that the value is not set and marks the form control with an error (required).

Oddly enough, the code works (without ExpressionChangedAfterItHasBeenCheckedError) whenever I move the code <app-select [formControlName]="'user'" [options]="userOptions"></app-select> to the top of the file.

How can I fix this problem in the SelectComponent in a way such that the outer component does not have to implement any more logic?


Solution

  • TLDR: you can find your working example here: https://stackblitz.com/edit/angular-f8fiux

    There are several steps to be done to make it work:

    Modify writeValue method. writeValue is the input of your custom component, so it shouldn't call onChange method (which is output of the component), otherwise it can lead to incorrect interaction.

    writeValue(value: any): void {
        this.selectFormControl.setValue(value, { emitEvent: false });
    }
    

    Here emitEvent: false is passed so as to not trigger change event of selectFormControl. To understand logic of Value Accessors I recommend this article.


    Modify ngOnChange method. It should set inner value of your component (through writeValue) and then emit it for outer consumer (through onChange). For onChange you have to schedule either microtask (Promise.resolve) or macrotask (setTimeout), otherwise the data will be emitted in the same change detection cycle, causing ExpressionChangedAfterItHasBeenCheckedError

    ngOnChanges(simpleChanges: SimpleChanges): void {
      if (simpleChanges.options) {
        if (this.options.length === 1) {
          if (this.selectFormControl.value !== this.options[0]) {
            this.writeValue(this.options[0]);
            setTimeout(() => {
              this.onChange(this.options[0]);
            });
          }
        } else {
          this.writeValue(null);
        }
      }
    }
    

    It's better not to mix Reactive Forms and Template-driven forms, so I recommend you to get rid of (ngModelChange)="onChange($event)" in template as it's asynchronous data flow and needs more complicated handling. Instead of that, you can add a simple subscription in constructor (don't forget to unsubscribe, here I omitted it)

    constructor(private cdr: ChangeDetectorRef) {
      this.selectFormControl.valueChanges.subscribe((value) => {
        this.onChange(value);
      });
    }
    

    You can find detailed information about async and sync flow in Angular docs