[Solved] @tracked array and each helper problem

Hi everyone!

I have a interesting problem with @tracked array and each helper.

in controller

@tracked
belgeList = [
  {
    adi: "A Belgesi",
    id: "dosya1",
    yuklenen: ""
  },
  {
    adi: "B Belgesi",
    id: "dosya2",
    yuklenen: ""
  },
  {
    adi: "C Belgesi",
    id: "dosya3",
    yuklenen: ""
  }
];

@action
dosyaYuklemeyeHazirla(index, yuklenenDosya) {
  let belgeListesi = [...this.belgeList];
  this.belgeList = [];

  belgeListesi[index]["yuklenen"] = yuklenenDosya.name;

  // update new values
  this.belgeList = [...belgeListesi];
}

in hbs file

{{#if this.belgeList}}
 {{#each this.belgeList as |belge index|}}
    <tr>
      <td>{{belge.adi}}</td>
      <td>
        {{#if belge.yuklenen}}
          {{belge.yuklenen}}
        {{else}}
          <span class="text-danger">Henüz yükleme gerçekleştirilmemiştir.</span>
         {{/if}}
       </td>
       <td class="text-right">
         <FileUpload @name={{belge.id}} @onfileadd={{action this.dosyaYuklemeyeHazirla index}}>
           <a class="btn btn-success btn-sm">YĂĽkle</a>
         </FileUpload>
         <button class="btn btn-danger btn-sm" type="button" disabled={{if belge.yuklenen false "disabled"}}>Sil</button>
       </td>
     </tr>
 {{/each}}
{{/if}}

My object’s yuklenen prop wont update on my hbs. Any advice?

Hi @themesama, I think the problem is that @tracked (at least in its current implementation) works with simple types but you’re trying to use an array of objects, while you’ve marked the array as tracked it doesn’t automatically “reach in” and track each property of each object in the array.

IIRC there are a couple ways around this, you could make your own extended object type, or you can use Ember.set (instead of regular ES assignment) to set the value which updates properly behind the scenes, or you can use the tracked-built-ins addon to provide tracked versions of the complex data types like Object, Array, etc.

1 Like

Doing the assignment like this:

Is a correct way to tell glimmer that you’ve made changes to the array. In fact, with tracked properties (unlike set), you don’t even need to create a new object that is !==. Mutating and reassigning the old array also works, like:

let belgeListesi = this.belgeList;
belgeListesi.push(newThing);
this.belgeList = belgeListesi;

A way to think about it is this: every time you do this.belgeList = anything, that will cause the #each that is consuming this.belgeList to re-render, and there are no === comparisons involved at that point.

However, #each itself tries to be efficient when it rerenders, and it has its own strategies for determining which elements in the array really should be rerendered. By default, it uses ===, but you can customize this behavior via the key argument to #each. In your example, the members of the list remain === before and after the update, so #each will not destroy and recreate its body for any of the array elements.

That means re-rendering will proceed down to the individual fields, like {{belge.yuklenen}}. Since these are not tracked, they won’t know to re-render either.

So there are two different places you could solve this problem. One is to rerender the entire body of the #each for the element that has changed by giving it a new identity:

@action
dosyaYuklemeyeHazirla(index, yuklenenDosya) {
  let belgeListesi = this.belgeList;

  // create a new object with all the same fields as the old object, plus the new `yuklenen`
  belgeListesi[index] = { ...belgeListesi[index], yuklenen: yuklenenDosya.name };

  // and assign to the array so that the `each` will rerender and see our new object
  this.belgeList = belgeListesi;
}

The alternative is to give the individual elements tracked properties:

class MyItem {
  @tracked yuklenen;
  constructor(args) {
    Object.assign(this, args);
  }
}

// ...

@tracked belgeList = [
  new MyItem({
    adi: "A Belgesi",
    id: "dosya1",
    yuklenen: ""
  }
]

@action
dosyaYuklemeyeHazirla(index, yuklenenDosya) {
  this.belgeList[index]["yuklenen"] = yuklenenDosya.name;
}

If there is expensive stuff being rendered in the body of the each, this second solution will be faster because it will only rerender the yuklenen field and nothing else. The tradeoff is that you need to introduce another class for the individual items so you can set up their tracking.

7 Likes

Thank you for the detailed explanations and examples.

Regards, Themesama

Hmmm … I tried this, on this:

{{#each this.options as |option|}}
  <li>
    <OptionsRadio @option={{option.id}} @chosen={{option.chosen}} @sendRadioClick={{this.sendRadioSelect}} />
    <span class="option-text">{{option.html}}</span>
  </li>
{{/each}}

My “callback” from the inner component (within the parent code) is:

export default class OptionsComponent extends Component {
  @tracked options = this.args.attrs.options;
  
  @action
  sendRadioSelect(option) {
    let options = this.options
    options.forEach ((candidate) =>{
      if (candidate.id === option) {
        candidate.chosen = true;
      } else {
        candidate.chosen = false;
      }
    });
    this.options = [...options];
  }

Neither this.options = [...options]; nor this.options = options; caused the each loop to be rerun (checked by adding a log entry in the loop) … and the child components are definitely not re-rendered.

So I’m stuck unable to create a traditional “radio button” arrangement in Glimmer … :thinking:

Your example isn’t doing either of the things I suggested above, which in your case would be:

  1. Make chosen tracked inside each candidate.
  2. Or replace whole candidates with new objects each time instead of mutating inside them.

I would recommend the first option, because it’s bug-prone to copy your args onto this, because then you won’t see changes if your caller passes you different options.

1 Like

thanks for looking.

This is inside of the child:

export default class OptionsRadioComponent extends Component {
  @tracked chosen = this.args.chosen;
  @tracked option = this.args.option;

  @action
  sendClick() {
    this.chosen = !this.chosen;
    this.args.sendRadioClick(this.option);
  }
};

and the template:

<div class="options-radio" onclick={{action this.sendClick}}>
  {{#if this.chosen}}
    {{d-icon "circle" }}
  {{else}}
    {{d-icon "far-circle"}}
  {{/if}}
</div>

So i’m tracking internally

but yeah, I’m copying from args.

Aren’t I doing that? Each radio child component has a tracked chosen

I’m replacing options with a complete new object on each change?

That’s why expected the entire each loop to run again.

But that means the thing that’s tracked is different, because it’s a different copy. The chosen in each child is indeed tracked, but it has no effect on the value in the parent.

@tracked should on the roots of state. OptionsComponent takes an options argument. So somebody else higher in the call stack actually owns options. They created it and passed it downward into OptionsComponent which in turn passes it down to OptionsRadioComponent.

You don’t want OptionsComponent or OptionsRadioComponent to create any new state, they’re just trying to use state that they were given. So they shouldn’t need to use @tracked at all.

If you want to be able to mutate chosen on the options, the place that originally created options should make them be tracked. Then everything else can just read and write them and not worry about @tracked at all.

Here’s a runnable example that refactors your components:

1 Like

thanks, but here’s the rub:

This is a radio array. I need each radio click to reset the rest to false - there can only be one that is true (this is not a collection of check boxes)

So it can’t just rely on state being passed down (from above “options”)

Changes of state locally at Options level have to update all of the children with a change on any of them.

So Options needs to control state of all the children and update all of them if one is changed (or at least the last true one)

That’s why I have it communicating the change with an action up as it needs to affect at least one of the other children.

Ah, that case is actually slightly easier because there’s really only a single tracked value (because it’s not really true that each option is independently chosen or not chosen):

1 Like

This is great, thanks very much for your time!

So the entire loop is re-rendered because the chosen object is assigned to a new object which is an argument of the options Component which itself has an each loop internally.

It’s a shame that you can’t seem to force a re-render of an each loop by assignment of the array to a new object it relies upon within the same Component even when that is tracked, e.g. by

 @tracked options

and later to re-render:

this.options = [...options];

This doesn’t seem to work within the same Component?

That makes me wonder if you were to assign the array to a new object in a parent Component, the re-evaluation of the argument would make the each loop re-render also, but this doesn’t work if the object is not being passed via an argument.

So what is the lesson here?

That object change evaluation for re-rendering seems to be occurring at the interface of the Component and does not happen within a Component?

So the general solution is the push the loops into a child component and reassign in the parent?

OK scratch that.

Your solution works, but the alternative approach of re-assigning the options object is not enough even in the parent component. This does not cause the child loop to rerun.

My issue is solved though, and your solution is clearly more efficient, so all good. I’m a bit unclear why loops are not rerun for reassigned arrays though.

I’m a bit unclear why loops are not rerun for reassigned arrays though.

They are though. It’s just that when they run, the {{#each}} uses its @key= argument to decide which items count as “the same”. When it’s not set, it defaults to using ===.

So this.array = [...this.array] will cause the loop to rerender, but every item it finds will be === what it saw before, so each part will remain stable. You can see the difference if instead you do this.array = [...this.array, newThing]. The newThing will indeed appear.

1 Like

thanks Edward that’s incredibly helpful too!

{{#each @options key="chosen" as |option| }}

NB I found I still need to reassign the options object to kick off that evaluation.