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?
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 assign0
... 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 of0
.
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)
});
});
}
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
ofFooComponent
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 thetrue
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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With