Mobx how to cache computed values?

Viewed 2300

I'am using Mobx to build a webgl game engine. I am not using it with react. I am using it to enhance an entity component system. I have entity classes like

import {observable, observe, computed, autorun} from 'mobx';

class Entity {
  @observable position = [0,0,0]
  @observable rotation = [0,0,0]

  @computed get modelMat(){
    return position * rotation;
  }
}

I use this entity like :

var ent = new Entity();
entity.position = [0,10,0];
if(entity.modelMat == 6){
  // do something
}

My understanding is that reading modelMat directly like that is not a best practice. It causes the computed to be recalculated. It is not cached. This is detrimental in my game engine as I might be accessing these computed values at a high velocity like 60fps.

This seems unintuitive to me because you define the computed using the get helper and then are not supposed to use it as a getter? The debug computedRequiresReaction setting is available to prevent this pattern of direct computed reading.

configure({
  computedRequiresReaction: true
});

My question then is how to cache or memoize these computed values that will be accessed at frequent intervals? To avoid this I have started using a pattern that uses autoruns, to update local variables when the computed changes. It looks like:

class Entity {
  @observable position = [0,0,0]
  @observable rotation = [0,0,0]

  modelMat = []

  constructor(){
    autorun(() => {
      this.modelMat = this.computedModelMat()
    })
  }

  @computed get computedModelMat(){
    return position * rotation;
  }
}

This enables an interface for the class so that ent.modelMat can still be accessed rapidly but its not re-computed each time. Is there a better suggested pattern for this? It seems redundant to have an autorun for each computed. some of my classes end up having many autorun handlers to cache these values.

3 Answers

Note that computed supports a keepAlive option, that will force mobx to cache the value, even when there are no observers. And it is actually more efficient than using autorun to observe, as there are some internal optimizations applied to this flag.

There is a little risk of memory leaks though: if anything referred to by the computed is still alive, the computed won't be cleaned up. However, if you are only referring to class local things this should be save.

Yes, your are actually using the recommended approach: https://github.com/mobxjs/mobx/issues/356

as long as a computed value is not used by a reaction, it is not memoized and so it just like a normal eager evaluating function. If you would would use the [getter] in an autorun this behavior will change and you won't see unnecessary computations.

...

the reason MobX works this way is that as long as a computed value is not in use by some reaction, it can simply be ignored. MobX doesn't recompute it all, and the computation doesn't keep any other computation alive.

But beware of memory leaks. The code in the question doesn't leak, but I'm not sure about all of your code:

const VAT = observable(1.2)
class OrderLine {
   @observable price = 10
   @observable amount = 1
   constructor() {
       // this autorun will be GC-ed together with the current orderline instance
       this.handler = autorun(() => {
           doSomethingWith(this.price * this.amount)
       })
       // this autorun won't be GC-ed together with the current orderline instance
       // since VAT keeps a reference to notify this autorun,
       // which in turn keeps 'this' in scope
       this.handler = autorun(() => {
           doSomethingWith(this.price * this.amount * VAT.get())
       })
       // So, to avoid subtle memory issues, always call..
       this.handler()
       // When the reaction is no longer needed!
   }
}

Basically what is happening is that you are stepping out of the mobx world and mobx is not concerned what is going on outside of it. In mobx system nothing is observing the computed value so there is no reason to keep it cached (in memory).

There is no nice way around this issue.

The best thing I could offer you is a little bit better developer experience when trying to write code the way you want / need to.

In the following example notice the cacheComputed() function. It takes the instance and property to be cached as string and simply wraps autorun around it.We use it in the constructor of the class. Also, make sure to dispose of the autorun if you are disposing of the instance itself. For that, I usually have a dispose() method on the instance that disposes of all reactions inside it.

You should always stop all reactions when you are done with them.

import { computed, autorun, observable, decorate } from "mobx";

function cacheComputed(instance, prop) {
  return autorun(reaction => {
    return instance[prop];
    //todo - maybe throw if 'prop' does not exist
  });
}

class A {
  constructor() {
    this.firstName = "Bob";
    this.lastName = "Marley";

    this.disposeFullName = cacheComputed(this, "fullName");
  }

  get fullName() {
    console.log("computed");
    return `${this.firstName} ${this.lastName}`;
  }

  dispose() {
    this.disposeFullName();
  }
}

decorate(A, {
  firstName: observable,
  lastName: observable,
  fullName: computed
});

const a = new A();

console.log(a.fullName); //cached
console.log(a.fullName); //cached
console.log(a.fullName); //cached

//--- force recompute
console.log("---- recalculate computed");

a.lastName = "Dylan";
console.log(a.fullName); //recomputed
console.log(a.fullName); //cached
a.dispose(); // after this fullName will be recomputed always

Check it out on CodeSandbox

Related