I'm having trouble finding a good solution to a particular problem in my react/mobx application. Let's say I have a number of boxes which I want to position on the screen in a certain layout in which the position of each box depends on the positions of all other boxes (think force directed layout or similar). There is also a re-layouting happening depending on some other @observable variables such as sorting by size of the box or filtering by category. In addition, the boxes are interactive and get highlighted if one is hovered with the mouse. I would also like this to be reflected in the boxes data as a highlighted property set on the boxes.
Coming from redux, my initial approach would have been to have some observable ui-state variables such as sortBy and filterBy and then have a @compute getter that returns a fresh array of fresh box objects with their positions and highlighted property set each time one of the variables changes.
const boxes = [{ id: '1', category: 'bla', size: 5 }, ...];
class UiStore {
@observable sortBy = 'size';
@observable filterBy = undefined;
@observable hoveredBox = undefined;
/* Some @action(s) to manipulate the properties */
}
const uiStore = new UiStore();
class BoxesStore {
@computed
get positionedBoxes() {
const filteredBoxes = boxes.filter(box =>
box.category === uiStore.filterBy
);
// An array of positions based on the filtered boxes etc.
const positions = this.calculateLayout(filteredBoxes);
return boxes.map((box, i) => {
const { x, y } = positions[i];
return {
...box,
highlighted: uiStore.hoveredBox === box.id,
x,
y,
};
});
}
}
In theory this works fine. Unfortunately I don't think this is the most efficient solution in terms of performance. With a lot of boxes, creating new objects on every change and on every hover can't be the best solution. Also according to the mobx best practices it is advised to think about the whole topic slightly differently. The way I understand it, in mobx you are supposed to create actual instances of boxes each having @observable properties themselves. Also one is supposed to only ever hold one instance of each box in the boxesStore. If I were for example to add an additional @computed getter that calculates a different kind of layout I'm now technically holding multiple versions of the same box in my store, right?
So I tried to find a solution with creating a Box class instance for each box item with an @computed getter for the highlighted property. This works, but I can't wrap my head around how and when to set the position of the boxes. One way would be to go with a similar approach to the one above. Calculating the positions in a @computed getter and then setting the x, y position of each box in that same function. This might work, but it definitely feels wrong to set the positions within a @compute.
const boxes = [{ id: '1', category: 'bla', size: 5 }, ...];
/* uiStore... */
class Box {
constructor({id, category, size}) {
this.id = id;
this.category = category;
this.size = size;
}
@computed
get highlighted() {
return this.id === uiStore.highlighted
}
}
class BoxesStore {
@computed
get boxes() {
return boxes.map(box => new Box(box));
}
@computed
get positionedBoxes() {
const filteredBoxes = this.boxes.filter(box =>
box.category === uiStore.filterBy
);
// An array of positions based on the filtered boxes etc.
const positions = this.calculateLayout(filteredBoxes);
const positionedBoxes = filteredBoxes.map(box => {
const { x, y } = positions[i];
box.x = x;
box.y = y;
})
return positionedBoxes;
}
}
A different approach would be to calculate all the positions in a @compute on boxesStore and then accessing these positions within the box.
const boxes = [{ id: '1', category: 'bla', size: 5 }, ...];
/* uiStore... */
class Box {
constructor({id, category, size}, parent) {
this.id = id;
this.category = category;
this.size = size;
this.parent = parent;
}
@computed
get position() {
return _.find(this.parent.positionedBoxes, {id: this.id})
}
}
class BoxesStore {
@computed
get boxes() {
return boxes.map(box => new Box(box));
}
@computed
get positionedBoxes() {
const filteredBoxes = this.boxes.filter(box =>
box.category === uiStore.filterBy
);
// An array of positions based on the filtered boxes etc.
return this.calculateLayout(filteredBoxes);
}
}
Again, this might work but it feels super complicated. As a last approach I was thinking about doing something with mobx reaction or autorun to listen to changes in the uiStore and then setting the position of the boxes via an action like so:
const boxes = [{ id: '1', category: 'bla', size: 5 }, ...];
/* uiStore... */
class Box {
@observable position = {x: 0, y: 0};
@action
setPosition(position) {
this.position = position;
}
}
class BoxesStore {
constructor() {
autorun(() => {
const filteredBoxes = this.boxes.filter(box =>
box.category === uiStore.filterBy
);
this.calculateLayout(filteredBoxes).forEach((position, i) =>
filteredBoxes[i].setPosition(position);
)
})
}
@computed
get boxes() {
return boxes.map(box => new Box(box));
}
}
I like this approach the most so far but I don't feel like any of these solutions are ideal nor elegant. Am I missing some obvious approach to this type of issue? Is there a simpler and more practical approach to solving this?
Thanks for reading through all of this :)