sir_thursday
sir_thursday

Reputation: 5409

Changing ContentChildren models on QueryList.changes

Suppose I have a parent component with @ContentChildren(Child) children. Suppose that each Child has an index field within its component class. I'd like to keep these index fields up-to-date when the parent's children change, doing something as follows:

this.children.changes.subscribe(() => {
  this.children.forEach((child, index) => {
    child.index = index;
  })
});

However, when I attempt to do this, I get an "ExpressionChangedAfter..." error, I guess due to the fact that this index update is occurring outside of a change cycle. Here's a stackblitz demonstrating this error: https://stackblitz.com/edit/angular-brjjrl.

How can I work around this? One obvious way is to simply bind the index in the template. A second obvious way is to just call detectChanges() for each child when you update its index. Suppose I can't do either of these approaches, is there another approach?

Upvotes: 11

Views: 11687

Answers (5)

sancelot
sancelot

Reputation: 2053

I really don't know the kind of application, but to avoid playing with ordered indexes , it is often a good idea to use uid's as index. Like this, there is no need to renumber indexes when you add or remove components since they are unique. You maintain only a list of uids in the parent.

another solution that may solve your problem , by dynamically creating your components and thus maintain a list of these childs components in the parent .

regarding the example you provided on stackblitz (https://stackblitz.com/edit/angular-bxrn1e) , it can be easily solved without monitoring changes :

replace with the following code :

app.component.html

<hello [(model)]="model">
  <foo *ngFor="let foo of model;let i=index" [index]="i"></foo>
</hello>

hello.component.ts

  • remove changes monitoring
  • added foocomponent index parameter

    import { ContentChildren, ChangeDetectorRef, Component, Input, Host, Inject, forwardRef, QueryList } from '@angular/core';

    @Component({
      selector: 'foo',
      template: `<div>{{index}}</div>`,
    })
    export class FooComponent  {
      @Input() index: number = 0;
    
      constructor(@Host() @Inject(forwardRef(()=>HelloComponent)) private hello) {}
    
      getIndex() {
        if (this.hello.foos) {
          return this.hello.foos.toArray().indexOf(this);
        }
    
        return -1;
      }
    }
    
    @Component({
      selector: 'hello',
      template: `<ng-content></ng-content>
        <button (click)="addModel()">add model</button>`,
    })
    export class HelloComponent  {
      @Input() model = [];
      @ContentChildren(FooComponent) foos: QueryList<FooComponent>;
    
      constructor(private cdr: ChangeDetectorRef) {}
    
    
    
      addModel() {
        this.model.push({});
      }
    }
    

I forked this working implementation : https://stackblitz.com/edit/angular-uwad8c

Upvotes: 0

Marshal
Marshal

Reputation: 11081

As stated, the error comes from the value changing after the change cycle has evaluated <div>{{index}}</div>.

More specifically, the view is using your local component variable index to assign 0... which is then changed as a new item is pushed to the array... your subscription sets the true index for the previous item only after, it has been created and added to the DOM with an index value of 0.


The setTimout or .pipe(delay(0)) (these are essentially the same thing) work because it keeps the change linked to the change cycle that this.model.push({}) occurred in... where without it, the change cycle is already complete, and the 0 from the previous cycle is changed on the new/next cycle when the button is clicked.

Set a duration of 500 ms to the setTimeout approach and you will see what it is truly doing.

 ngAfterContentInit() {
    this.foos.changes.pipe(delay(0)).subscribe(() => {
      this.foos.forEach((foo, index) => {
        setTimeout(() => {
          foo.index = index;
        }, 500)
      });
    });
  }
  • It does indeed allow the value to be set after the element is rendered on the DOM while avoiding the error however, you will not have the value available in the component during the constructor or ngOnInit if you need it.

The following in FooComponent will always result in 0 with the setTimeout solution.

ngOnInit(){
    console.log(this.index)
  }

Passing the index as an input like below, will make the value available during the constructor or ngOnInit of FooComponent


You mention not wanting to bind to the index in the template, but it unfortunately would be the only way to pass the index value prior to the element being rendered on the DOM with a default value of 0 in your example.

You can accept an input for the index inside of the FooComponent

export class FooComponent  {
  // index: number = 0;
  @Input('index') _index:number;

Then pass the index from your loop to the input

<foo *ngFor="let foo of model; let i = index" [index]="i"></foo>

Then use the input in the view

selector: 'foo',
  template: `<div>{{_index}}</div>`,

This would allow you to manage the index at the app.component level via the *ngFor, and pass it into the new element on the DOM as it is rendered... essentially avoiding the need to assign the index to the component variable, and also ensuring the true index is provided when the change cycle needs it, at the time of render / class initialization.

Stackblitz

https://stackblitz.com/edit/angular-ozfpsr?embed=1&file=src/app/app.component.html

Upvotes: 11

deviprsd
deviprsd

Reputation: 378

The problem here is that you are changing something after the view generation process is further modifying the data it is trying to display in the first place. The ideal place to change would be in the life-cycle hook before the view is displayed, but another issue arises here i.e., this.foos is undefined when these hooks are called as QueryList is only populated before ngAfterContentInit.

Unfortunately, there aren't many options left at this point. @matt-tester detailed explanation of micro/macro task is a very helpful resource to understand why the hacky setTimeout works.

But the solution to an Observable is using more observables/operators (pun intended), so piping a delay operator is a cleaner version in my opinion, as setTimeout is encapsulated within it.

ngAfterContentInit() {
    this.foos.changes.pipe(delay(0)).subscribe(() => {
        this.foos.forEach((foo, index) => {
          foo.index = index;
        });
    });
}

here is the working version

Upvotes: 2

Reza
Reza

Reputation: 19843

use below code, to make that changes in the next cycle

this.foos.changes.subscribe(() => {

  setTimeout(() => {
    this.foos.forEach((foo, index) => {
      foo.index = index;
    });
  });

});

Upvotes: 1

Matt Tester
Matt Tester

Reputation: 4814

One way is update the index value using a Macro-Task. This is essentially a setTimeout, but bear with me.

This makes your subscription from your StackBlitz look like this:

ngAfterContentInit() {
  this.foos.changes.subscribe(() => {

    // Macro-Task
    setTimeout(() => {
      this.foos.forEach((foo, index) => {
        foo.index = index;
      });
    }, 0);

  });
}

Here is a working StackBlitz.

So the javascript event loop is coming into play. The reason for the "ExpressionChangedAfter..." error is highlighting the fact that changes are being made to other components which essentially mean that another cycle of change detection should run otherwise you can get inconsistent results in the UI. That's something to avoid.

What this boils down to is that if we want to update something, but we know it shouldn't cause other side-effects, we can schedule something in the Macro-Task queue. When the change detection process is finished, only then will the next task in the queue be executed.


Resources

The whole event loop is there in javascript because there is only a single-thread to play with, so it's useful to be aware of what's going on.

This article from Always Be Coding explains the Javascript Event Loop much better, and goes into the details of the micro/macro queues.

For a bit more depth and running code samples, I found the post from Jake Archibald very good: Tasks, microtasks, queues and schedules

Upvotes: 3

Related Questions