Marteiro
Marteiro

Reputation: 175

Mobx not updating to changes in array

I'm having trouble getting MobX to render when updating array. Here is a simplified version of the code:

import React, { useState } from 'react'
import { flow, makeObservable, observable } from 'mobx'
import { observer } from 'mobx-react'

import { ResourceList } from './ResourceList'
import { ResourceItem } from './ResourceItem'


export const View = observer(() => {
    const [{
        items,
    }] = useState<MyState>(new MyState())

    return <ResourceList items={items} />
})


export class MyState {
    constructor() {
        makeObservable(this)
        this._fetch()
    }

    @observable public items: ResourceItem[] = []

    private _fetch = flow(function* (this: MyState) {
        const items = yield fetchItems('myitems')
        this.items = items
    })
}

As you can see I'm trying to consume the array in <ResourceList items={items} /> and it does not work.

The weird thing is that if works if I destruct the array like so <ResourceList items={[...items]} />

is also works if I just console.log a property of the array:

console.log(items.length)
return <ResourceList items={items} />

So it really looks like Mobx is not understanding that the array ins being used in the markup.

What would be the proper way to fix this? Isn't it supposed to have something I configure inside the state class so make it happen?

Thanks

Upvotes: 7

Views: 9669

Answers (2)

Mohamed Allal
Mohamed Allal

Reputation: 20870

Not Mobx problem

The problem isn't a mobx one. When you trigger the change. The data trigger a re-render (can be checked). What's not re-rendering is the ResourceList component. That expects a new ref. Because myState.items is an observer and so myState. When you make an assignment the same observer object will be updated. Rather than having a new one created. Which means the same reference. By restructuring or using slice() a new array ref is created. And passed down through the prop. Making ItemsList component to re-render.

When can it be a mobx problem

For mobx to trigger a re-render =>

two elements come into play:

  • observable access operation
  • Which observable and how to trigger a change for it

mobx track change through access, not values, and re-render through actions that make change

ref: https://mobx.js.org/understanding-reactivity.html#mobx-tracks-property-access-not-values

Here we have the items observable of type ObservableArray

And the MyState object observable of type ObservableDynamicObject

items is part of myState. And it is tracked on the myState observable. Due to the items property access that happen when the restructuring happened. No access no tracking.

And to trigger a change you have to make an assignment to that object. myState.items = newRef. Making this.items = yield fetchItems('myitems') would do it.

So basically if you do the change through an assignment like above. And the assignment of a new ref (myState.items = myState.items wouldn't trigger aa re-rendering).

The assignment happens on the myState ObservableDynamicObject object and because it is a proxy it would have the set() handler intercept it and handle the tracking magic.

// ...

let counter = 0;

export default observer(() => {
  const [{ items }] = useState(() => new MyState());
  counter++;
  // No items array access is made
  return <>{counter}</>;
});

class MyState {
  constructor() {
    makeAutoObservable(this, { autoBind: true });
    this.update();

    setTimeout(() => {
      this.update(); // Will render. Because an assignment on myState
             // happened for the items prop. And the access happened 
             // through the { items } destructuring
      setTimeout(() => {
        this.update(); // will re-render
        setTimeout(() => {
          this.update(); // will re-render
        }, 5000);
      }, 5000);
    }, 5000);
  }

  items = [];

  update = flow(function* () {
    const items = yield ["Mohamed", "Fahima", "The Magician"];
    // this.items = items
    this.items = [...this.items, items];
  });
}

You can check the playground here

Array and mutations

Now what if the change is rather coming through this.items.push() myState.items.push(). In this case. The push action, method, is a property of the myState.items element that is of ObservableArray type. And it's a proxy as well. When push is called. The Administration object splice handler is used. (push and splice are handled by the same underlying function). We don't care much about the internal. The point is that push() on items will trigger change if there is elements tracked on items observable and not myState. And that would make making an access on items on the component render function a must for it's re-rendering. You do that through items[index], items.length. And anything like items.map(), [...items] (destructuring) would automatically make index access to the array (observable). Caught by the proxy get() handler. And the administration object handles the magic of tracking the array for change.

So if your array is not re-rendering because you used items.push(). Automatically make an access to the items array. like items.length to just test. And u have always to use an array if you are tracking it for change. So either items.map() or [...items] or items.slice() or anything that make an index access. Or access `items.length``.

// ...

let counter = 0;

export default observer(() => {
  const [{ items }] = useState(() => new MyState());
  counter++;
  // No access to items array was made
  return <>{counter}</>;
});

class MyState {
  constructor() {
    makeAutoObservable(this, { autoBind: true });
    this.update();

    setTimeout(() => {
      this.update(); // No re-rendering (nothing tracked) (no access made)
      setTimeout(() => {
        this.update(); // No re-rendering
        setTimeout(() => {
          this.update(); // No re-rendering
        }, 5000);
      }, 5000);
    }, 5000);
  }

  items = [];

  update = flow(function* () {
    const items = yield ["Mohamed", "Fahima", "The Magician"];
    this.items.push(...items);
  });
}

This example will stop the counter at 4 in a strict mode everything run twice. so 2 first render and 2 for re-render due to mobx observer higher-order component which would trigger re-rendering once (2 strict-mode).

You can check the playground here

And if we just add any of items.slice() or items.length or items[0] or [...items] or items.map(() => true) it will re-render ok at the update() with push.

let counter = 0;

export default observer(() => {
  const [{ items }] = useState(() => new MyState());
  counter++;
  // items[0];
  // items.length;
  // items.slice()
  items.map(() => true);
  return <>{counter}</>;
});

class MyState {
  constructor() {
    makeAutoObservable(this, { autoBind: true });
    this.update();

    setTimeout(() => {
      this.update(); // Will re-render (array observable is tracked due to the access)
      setTimeout(() => {
        this.update(); // will re-render
        setTimeout(() => {
          this.update(); // will re-render
        }, 5000);
      }, 5000);
    }, 5000);
  }

  items = [];

  update = flow(function* () {
    const items = yield ["Mohamed", "Fahima", "The Magician"];
    this.items.push(...items);
  });
}

And here the playground again. For this one, the counter will reach 10. Re-rendering happens each time when the update() runs through the chained setTimeout

For more details, you can check my answer here: https://stackoverflow.com/a/73564630/7668448

Upvotes: 0

Tholle
Tholle

Reputation: 112787

This is discussed in the Understanding reactivity part of the documentation. When you are writing items you are not dereferencing a property of the items array, so your View observer component doesn't track the items array. You could slice the array or spread it like you did in your example.

<ResourceList items={items.slice()} />

You also want to create the MyState instance once with the help of a function given to useState, so you don't create an instance of the class every render that you don't use, and that will overwhelm the network.

const [{ items }] = useState<MyState>(() => new MyState());

Upvotes: 3

Related Questions