5

I have created a custom component that contains a form <address></address>. And I have a parent component that has an array of these:

@ViewChildren(AddressComponent) addressComponents: QueryList<AddressComponent>;

So the parent can contain a collection of these elements and the user can add and remove them based on the number of addresses they will be entering.

The parent also has a button to proceed after the user has entered all desired addresses. However, the <address> component must be filled out correctly so I have a public getter on the <address> component:

get valid(): boolen {
  return this._form.valid;
}

Back to the button on the parent. It needs to be disabled if any of the <address> components are invalid. So I wrote the following:

get allValid() {
   return this.addressComponents && this.addressComponents.toArray().every(component => component.valid);
}

And in the parent template:

<button [disabled]="!allValid" (click)="nextPage()">Proceed</button>

But angular doesn't like this because addressComponents are not defined in the parent until ngAfterViewInit lifecycle event. And since it immediately runs ngOnViewInit() I get two different values for the expression check which causes the error. (At least that's what I think is going on).

How do I use a property in my template that depends on ngAfterViewInit? Or what is the best way to inform my parent that all of its children are valid?

The Error Message:

Expression has changed after it was checked. Previous value: 'false'. Current value: 'true'

Update:

So I console.loged the return value of allValid and noticed the first time it was undefined. This was to be expected as this.addressComponents are undefined until ngAfterInit. The next log it was true and this was surprising as I didn't have any <address> components on the page (yet). I am using mock data (all valid, though) in ngOnInit of the parent component to create a component. I did learn that ([].every... returns true on an empty array). So the third call to the console.log was returning false. Again, I am a little surprised because all my data is valid. On the 4th log it was returning true which is what I expected. So I'm assuming this final value being returned is what Angular disliked.

Anyway, I was able to sort of solve this. I don't know if I'm actually fixing the problem or just suppressing the error. I do not like this solution so I am going to keep the question open for a better solution.

get allValid() {
  return this.addressComponents && this.addressComponents.length > 0 && this.addressComponents().toArray().every(component => component.valid);
}
adam-beck
  • 5,659
  • 5
  • 20
  • 34

3 Answers3

1

So what I think is happening:

The first wave of change detection gets you a false for your function, then your parent component finds out this information after the view is instantiated (then returns true). In "dev" mode, Angular runs change detection twice to ensure that changes don't happen AFTER change detection (as change detection should detect all of the changes, of course!)

According to the answer found here:

Angular2 - Expression has changed after it was checked - Binding to div width with resize events

using AfterViewInit can cause these issues, as it may run after the change detection has completed.

Wrapping your assignment in a timeout will fix this, as it will wait a tick before setting the value.

ngAfterViewInit(){
    setTimeout(_ => this.allValid = this.addressComponents && this.addressComponents.toArray().every(component => component.valid));    
}

Due to these reasons, I would not use a getter on a template variable like that, as the view initializing may change the value after change detection has finished.

Community
  • 1
  • 1
chrispy
  • 3,552
  • 1
  • 11
  • 19
  • I actually tried this (I read that same SO question). However, I need the parent form to update based on all the child `
    ` components on each digest(?) cycle. This gives me the same error. I have solved the problem but it really isn't that ideal. I will explain in the question.
    – adam-beck Feb 04 '17 at 00:57
  • Kind of like what was described below, I would have had the children emit their statuses when they were instantiated, and have the parent catch those hooks and act accordingly. – chrispy Feb 04 '17 at 01:08
0

If I understand, you'll probably need to come up with a way in the parent to track how many instances of the child there are, and the child will need an EventEmitter that informs the parent when it's valid or becomes invalid.

So in the parent you could use an array to track how many address instances there are..

Parent Component

addressForms: Array<any> = [{ valid: false }];
addAddressForm() {
  this.addressForms.push({ valid: false ));
}
checkValid() {
  // somehow loop through the addressForms, make sure all valid
  let allValid: boolean = false;
  for (var i = this.addressForms.length - 1; i >= 0; i--) {
    if (this.addressForms[i].value && allValid === false)
      allValid = true;
  }
  return allValid;
}

Parent Template

<div *ngFor="let form of addressForms; let i = index">
  <address (valid)="form.valid = true" (invalid)="form.valid = false"></address>
</div>
<button [disabled]="checkValid()">Next</button>

Address Component

@Output() valid: EventEmitter<any> = new EventEmitter();
@Output() invalid: EventEmitter<any> = new EventEmitter();
isValid: boolean = false;
check() {
  // call this check on field blurs and stuff
  if ("it's valid now" && !this.isValid) {
    this.isValid = true;
    this.valid.emit(null);
  }
  if ("it's not valid anymore" && this.isValid) {
    this.isValid = false;
    this.invalid.emit(null);
  }
}

That's the basic idea anyway, with some holes that are obvious enough to fill in. Hope that has some relevancy with what you're doing and I understood the question to begin with. Good luck!

BrandonReid
  • 1,264
  • 1
  • 13
  • 17
  • Instead of emitting null here, you could emit "isValid" and just use that as an input to checkValid() to reduce the duplication and the number of declared emitters. – chrispy Feb 04 '17 at 00:51
  • I wanted to avoid duplicating all this logic (as it exists in Reactive Forms provided by Angular). I solved the problem in a rather hacky way which I will explain in the question. I don't want to close because I hope there is a better way. – adam-beck Feb 04 '17 at 00:58
  • 1
    Good call @chrispy – BrandonReid Feb 05 '17 at 00:41
0

I faced the same issue when have been using that realy handy pattern :( Only short way I found atm to solve it is the next kind of a hack:

  @ViewChild(DetailsFormComponent) detailsForm: DetailsFormComponent;
  isInitialized = false;
  
  get isContinueBtnEnabled(): boolean {
    return this.isInitialized && this.detailsForm?.isValid();
  }

and

  ngAfterViewInit() {
    setTimeout(() => { // required
      this.isInitialized = true;
    });
  }
Vladimir Tolstikov
  • 2,463
  • 21
  • 14