Why does the Mithril child component state change not trigger an update?

666 Views Asked by At

The following code works as expected: it creates two counter buttons that persist their state and update on clicking:

let Counter = function (vnode) {
  let count = 0

  return {
    view: (vnode) => {
      return m("button",
        {
          onclick: function () {
            console.log(count++)
          }
        }, "Count: " + count)
    }
  }
}

let Counters = {
  view: () => [
    m(Counter),
    m(Counter),
  ]
}

m.mount(document.body, Counters)

However, if I define the array of Counter components in a separate variable and pass that to the Counters view function, then the view stops updating. The state persists and I can see incrementing count logged to console, but nothing changes on screen. This is the updated code:

let Counter = function (vnode) {
  let count = 0

  return {
    view: (vnode) => {
      return m("button",
        {
          onclick: function () {
            console.log(count++)
          }
        }, "Count: " + count)
    }
  }
}

let counters = 
  [
    m(Counter),
    m(Counter),
  ]
let Counters = {
  view: () => counters
}

m.mount(document.body, Counters)

Why would this be happening? This is a toy example of a more complicated Mithril application that I'm working on, where I would like to arbitrarily sort the array of child components.

2

There are 2 best solutions below

0
On BEST ANSWER

I was able to gather useful feedback in the Mithril Gitter chat and will post what I learned below.

The reason the counters in my example were not updating was because they were defined once inside the counters array and it was returned subsequently. As the same array is returned every time the view() function of Counters is called, Counters does not see a need to update as it thinks that the elements it is returning have not changed. Even though the Counter elements inside the array update, the reference to the array remains the same.

One way of handling this is by defining a factory function that would return a new sorted array of Counter elements each time the view() function on Counters is called. Furthermore, the application state then needs to be kept in a global location and the only parameter passed to each Counter is the index of the global counter it is referencing, like so:

let counterData = [0, 0]

let Counter = {
  view: ({
    attrs: {
      index
    }
  }) => {
    return m("button", {
      onclick: () => counterData[index]++
    }, "Count: " + counterData[index])
  }
}

let Counters = function () {
  const counters = () => {
    return counterData.sort((a, b) => a - b).map((count, i) => m(Counter, {
      index: i
    }, count))
  }

  return {
    view: () => counters()
  }
}

m.mount(document.body, Counters)

A working JSFiddle can be found here.

2
On

I think what may be happening is that the components are being "rendered" when you set up the array, because of how JavaScript evaluates expressions, the Mithril components in the counters array are already executed before you try to mount them later on, so the Mithril redraw mechanism (via m.mount or m.route) might not "know" about these components and are therefore not part of the redraw queue.

let counters = 
  [
    m(Counter),
    m(Counter),
  ]

I'm not sure what you're trying to achieve, but perhaps a better solution would be to hold the array of data in a pure JS data structure and when you need to actually render the components, you can map over the data structure, e.g.

let Counter = function (vnode) {
  return {
    view: (vnode) => {
      return m("button",
        {
          onclick: function () {
            console.log(this, vnode.attrs.count++);
          }
        }, "Counter #" + vnode.attrs.id +": " + vnode.attrs.count)
    }
  }
}

let counterList = [
  { id: 1, name: "Something 1", count: 0 },
  { id: 2, name: "Something 2", count: 0 },
  { id: 3, name: "Something 3", count: 0 },
]

let reorderCountersByCountAscending = () => {
  counterList.sort((a, b) => (a.count > b.count) ? 1 : -1)
}

let Counters = {
  view: () => {
    return m('div',
      m('button', { onclick: reorderCountersByCountAscending }, 'Reorder Children'),
      counterList
        .map((counterListItem) => m(Counter, counterListItem))
    )
  }
}

m.mount(document.body, Counters);

This isn't an ideal implementation, there's a lot of state mutation happening in very implicit ways and it can lead to much confusion in a real world application, but I hope it describes the approach.

Here are a few more examples to look at: https://jsfiddle.net/9hu2yd1s/