Glimmer Component Change Detection Not Working - Mutating Properties in @tracked Array

Hi everyone!

I’m having trouble with change detection in a Glimmer component and could use some guidance. When I mutate properties on objects within a `@tracked` array, the template doesn’t re-render.

**My Setup:**

```javascript

import Component from '@glimmer/component';

import { tracked } from '@glimmer/tracking';

import { fn } from '@ember/helper';

import { on } from '@ember/modifier';

import { action } from '@ember/object';



export default class CustomSidebarComponent extends Component {

  @tracked items = [

    {

      id: 'home',

      label: 'Home',

      expanded: false

    },

    {

      id: 'my-posts', 

      label: 'My Posts',

      expanded: false

    }

    // ... more items

  ];



  @action

  toggleExpanded(item) {

    item.expanded = !item.expanded; // This mutation doesn't trigger re-render

    console.log('Toggled:', item.label, item.expanded); // Logs correctly

  }



  <template>

    <div id="custom-sidebar">

      <ul>

        {{#each this.items key="@index" as |item|}}

          <li class="menu-item {{if item.expanded 'expanded'}}" {{on "click" (fn this.toggleExpanded item)}}>

            {{item.label}} {{if item.expanded "(expanded)" ""}}

          </li>

        {{/each}}

      </ul>

    </div>

  </template>

}

```

**The Problem:**

1. The click handler executes correctly

2. The property `item.expanded` gets updated (verified in console)

3. But the template doesn’t re-render - no class changes, no text changes

**What I’ve Tried:**

1. **Array reassignment** - `this.items = […this.items]` after mutation (doesn’t work)

2. **Immer for immutable updates** - Wanted to try this but can’t get npm imports to work in Discourse themes

3. **Different key strategies** - `key=“id”` instead of `key=“@index”`

**Questions:**

- Is mutating properties on objects within `@tracked` arrays supposed to work in Glimmer?

- Do I need to make individual objects tracked somehow?

- Is there a recommended pattern for this use case?

- Could this be related to running in a Discourse theme environment?

- Are there limitations with npm imports in Discourse themes that affect reactivity libraries?

**Environment:**

- Discourse theme component

- Glimmer components with `.gjs` files

- Latest Discourse version -updated today

Any insights would be greatly appreciated! Is there something fundamental I’m missing about Glimmer’s reactivity system?

Thanks!

2 Likes

Hmm, that’s weird. Doing the array assignment trick usually works.

An alternative I’ve found is to make the objects in the array be from a class with its own tracked properties. Something like:

class CustomSidebarItem {
  @tracked expanded = false;
  constructor(id, label) {
    this.id = id;
    this.label = label;
  }
}

export default class CustomSidebarComponent extends Component {

  @tracked items = [
    new CustomSidebarItem('home', 'Home'),
    new CustomSidebarItem('my-posts', 'My Posts'),
    ...
  ];
  // rest of your code
}

It can be more verbose than making a bunch of plain objects, but I’ve found it makes it easier to extend and reason around, especially if you need to do something like passing the data down to nested components.

4 Likes

Hi Alteras,

Thank you for your suggestion !!! It did work as suggested by you approach and really made our day bit easy ::)** :grinning_face:

2 Likes

I believe when you track an array like described in OP you’re tracking the array reference and not changes to the individual objects within the array

Another way to handle it is using trackedObject, we use this in a handful of places throughout Discourse

3 Likes

Thanks for your input. I do have another follow up questions :

Would using a library like immer work?
If yes, how should I include immer in discourse?
I have seen mentions of ‘copy it into assets from node_modules’, but I would prefer it if there is a way to do it via npm, or even a cdn link in the header tag

You can use loadScript() to load JS, even if the URL provided is an external URL. Using external services like jsdelivr should work with this.

import loadScript from "discourse/lib/load-script";

...

loadScript(URL).then(() => {
   // your code
})

Though I haven’t tested it extensively, a dynamic import should also work.

async function load() {
  const {
    default: myDefault,
    foo,
    bar,
  } = await import(URL);
  // rest of code
}

I should note that it is probably safer to save the NPM files directly into your code instead of relying on an external CDN, depending on how critical the JS is. Similarly, you may need to do a bit of bundling and preprocessing before being able to use it in the browser.


As for whether Immer will work with discourse, I don’t know.