Mobx: Is there any reason not to use structural comparison by default in computed properties?

Created on 30 Mar 2020  ·  7Comments  ·  Source: mobxjs/mobx

Hi there! First of all, huge thanks for the amazing library.

And the question. I am working on a performance-critical application with lots of recursive nested calculations and deriviations. And It seems to me that structural comparison will automatically provide better performance out of the box. I am thinking about implementing custom computed decorator which will use structural comparer and replace the default one everywhere.

But maybe there are drawbacks which I can't get off the top of my head?

❔ question

Most helpful comment

@s0ber the thing to keep in mind with structural comparisons (all of them, not just here but also with React.memo), is that they _only_ can save computations true negatives can happen.

Take for example the following:

class Data {
   @observable price = 100
   @observable unit = "EUR"

   @computed priceTag() {
      return { price: this.price, unit: this.unit }
   }
}

Now you might intuitively think you need to use @computed.struct in here because you are always creating a new data structure. But that is just a wast of performance: the computed will _only_ re-run if the backing data (either precisely price or unit) did actually change compared to their previous value (so e.g. data.price = 100 wouldn't trigger the computed in the above example). So you can easily deduct here that whenever the computed is triggered, it will always create an object that is structurally different as well from the previous value. So computed.struct only adds any value if you create a new data structure _and_ a difference in inputs can converge to a same output.

Whether structural equality is cheap like you mentioned, is hard to predict. It can be cheap, but remember that an === (the default) check is always O(1), the shallow equal check in my example above would be O(2), so not too bad either (but probably dozens of times slower than our reference equality due to all the reflection involved), but equality checking an arbitrary array isn't cheap at all, it as it is O(n). Put in an array of 10.000 items and it might hurt pretty badly.

And, yes, if struct detects equality, readers will always see the old value, so you can use ref equality downstream.

All 7 comments

And one more question. Imagine we have a computed property which returns some structure:

@computed.struct
get struct(): MyStructure {
  return { iAm: 'structure' }
}

And then we have another property which uses the result of previous property and passes it as an argument somewhere:

@computed
get passStruct() {
  const struct = this.struct // WILL IT BE THE SAME INSTANCE ALL THE TIME???
  const someOtherData = this.someOtherData // let's say this has changed
  return this.processStruct(struct, someOtherData)
}

processStruct = computedFn(function(struct: MyStructure, someOtherData: OtherComputed) {
  ...
})

So the question is... If our struct will be structurally equal between different calls, will the same instance be passed as an argument to this.processStruct, or new instance will be passed every time?

This is a very important detail because if we pass the new instance, we slow down subsequent structural comparisons, because instead of performing a simple and fast identity comparison, the whole structure is traversed. It's much faster to pass the same instance to speedup comparison rather than to pass structurally equal copy. It's also more efficient memory-wise. Please let me know if I am wrong and memoized instances are passed and I just reached the limits with my performance optimizations.

structural comparison will automatically provide better performance

The comparison(s) can cost more than the effect you're trying to prevent (eg react render).
Keep in mind that react also compares rendered elements to prevent dom mutation - preventing every possible render may not be the best strategy.
Or if values are changing rapidly, then comparison(s) may be falsy most of the time and therefore just slowing things down.

Ideally avoid structured data so you don't have to compare them (normalize state shape).

If our struct will be structurally equal between different calls, will the same instance be passed as an argument to this.processStruct, or new instance will be passed every time?

If there is at least one reaction that depends on struct, then value is memoized (same object is returned).
However if struct would be accessed behind condition, and the condition evaluates to false at some point, then reaction no longer depends on this value, it unsubscribes and memoized value is lost. So later when condition evalutes back to true it creates new object.
You can use keepAlive or abitrary autorun/reaction to prevent this.

@urugator Thanks for the response! The interesting thing here is that the second part of your question kind of contradicts with the first one. To me it seems that the comparison of memoized structures is really fast and trivial, because in this situation you won't really have to make deep comparisons — parts of the structures you are comparing will be completely equal, those will be same objects, so we really will just compare the references which will point to the same objects in memory.

It will be not the same if every time new instances will be returned. Am I getting it correctly?

You can compose computations with intermidiate memoized results like that. It's similar to splitting react component into multiple ones with memo, or composing selectors if you're familiar with redux.
However there is still a cascade of comparators, depending on the depth the change occured.
Note you may not need computedFn, you can express the arguments as another computed with shallow comparator:

@computed({ equals: comparer.shallow }) // note the comparer
get processedArgs() {
  return [this.struct, this.someOtherData]; // or object
}

@computed
get processed() {
  return process(...this.processedArgs);
}

@s0ber the thing to keep in mind with structural comparisons (all of them, not just here but also with React.memo), is that they _only_ can save computations true negatives can happen.

Take for example the following:

class Data {
   @observable price = 100
   @observable unit = "EUR"

   @computed priceTag() {
      return { price: this.price, unit: this.unit }
   }
}

Now you might intuitively think you need to use @computed.struct in here because you are always creating a new data structure. But that is just a wast of performance: the computed will _only_ re-run if the backing data (either precisely price or unit) did actually change compared to their previous value (so e.g. data.price = 100 wouldn't trigger the computed in the above example). So you can easily deduct here that whenever the computed is triggered, it will always create an object that is structurally different as well from the previous value. So computed.struct only adds any value if you create a new data structure _and_ a difference in inputs can converge to a same output.

Whether structural equality is cheap like you mentioned, is hard to predict. It can be cheap, but remember that an === (the default) check is always O(1), the shallow equal check in my example above would be O(2), so not too bad either (but probably dozens of times slower than our reference equality due to all the reflection involved), but equality checking an arbitrary array isn't cheap at all, it as it is O(n). Put in an array of 10.000 items and it might hurt pretty badly.

And, yes, if struct detects equality, readers will always see the old value, so you can use ref equality downstream.

@urugator @mweststrate Thanks for clarifying this. Will try to keep this in mind, especially the part about converging to the same output. My initial premise was that making struct comparer the default will automatically optimize all of such cases and I won't have to spend time on deciding if something is a good target for struct comparison or not.

But I see now there is a cost. Btw, I've tried to make it a default on my project and haven't noticed any substancial differences. Will let you know if tests will become faster. After introducing MobX the test suite on my project became 2 times faster which was a nice surprise — from 10 minutes to 5. :) So if there is a significant difference, test times should also at least somehow reflect this.

Closing as answered

Was this page helpful?
0 / 5 - 0 ratings