Reputation: 175
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
Reputation: 20870
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.
For mobx to trigger a re-render =>
two elements come into play:
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
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
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