I have a*:
- [x] Question: Feel free to just state your question. For a quick answer, there are usually people online at our Gitter channel
First consider the following simple, synchronous code. You can run it in JSFiddle.
const { observable, computed, autorun } = mobx;
class NumberInfo {
@observable value = 0;
@computed get square() {
return this.value * this.value;
}
}
const numberInfo = new NumberInfo();
autorun(() => console.log(`Value changed to ${numberInfo.value}.`));
autorun(() => console.log(`Square changed to ${numberInfo.square}.`));
numberInfo.value = 2;
numberInfo.value = 3;
Property square depends on property value. Changing value results in a new value for square, and through the magic of MobX, all updates are logged to the console.
Now let's assume that calculating the square of a number is an asynchronous operation: We have to make a server request, perform a database lookup or something similar. But I still want square to update (asynchronously) whenever value changes, and I still want to get the same log output.
Here is the best solution I could come up with. You can run it in JSFiddle. It's working, but there are a number of aspects I don't like.
const { observable, computed, autorun } = mobx;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
class NumberInfo {
@observable value = 0;
@observable square = 0;
constructor() {
autorun(async () => this.square = await this.getSquare());
}
async getSquare() {
const value = this.value;
// This could be a server request, database lookup, or similar
await sleep(500);
return value * value;
}
}
const numberInfo = new NumberInfo();
autorun(() => console.log(`Value changed to ${numberInfo.value}.`));
autorun(() => console.log(`Square changed to ${numberInfo.square}.`));
numberInfo.value = 2;
numberInfo.value = 3;
So here's what I don't like about my solution:
getSquare, that is, the code before the first await. In my code, I'm explicitly accessing this.value before the await. If I weren't doing this, MobX wouldn't understand that getSquare depends on value, so it wouldn't reliably call my autoruns. That's a source of errors, so it would be great if there was a cleaner way.autorun in the constructor to keep square updated. Given that JavaScript has no lifecycle management (destructors) for simple objects, there is no way for me to call the disposer. I'm not sure, but that probably creates some sort of memory leak within MobX.autorun in the constructor to keep square updated has another downside: square will now be updated whenever value changes, even if there is no code listening to square. That goes against the rule that only relevant values should be evaluated.Is there a better way to implement an asynchronous dependency between properties?
Regarding 2 and 3, you can use mobx-utils fromPromise and just mark the field as computed
@computed get square() {
code here
}
Unfortunately es7 is being needlessly conservative, so no async getters. You'll have to return fromPromise(realGetter()) for the code.
(1) is probably not fixable without switching to a custom promise implementation.
So if I understand you correctly, I could do
@computed get observableSquare() {
return fromPromise((async () => {
const value = this.value;
await sleep(500);
return value * value;
})());
}
@computed get square() {
return this.observableSquare.value;
}
This would give me a property observableSquare which depends on value (via getSquare) and has an observable property value. And if I only want the result, I can then introduce the property value, which depends on observableValue. Correct?
-- I just tried to set up a fiddle, but something's not working right yet.
Seems to be working fine. Once you change the value, expect that the computed will no longer have a value property until the whole thing completes (which takes 0.5s). So the first state change of square will be "undefined", then the square.
this. observableSquare.case({pending: ..., fulfilled: ..., rejected: ...}) will let you handle all promise states independently.
You're right, it's working. :-)
I just expanded the test scenario a bit to cover different timing issues (like an old promise finishing after a newer one), but it all seems to be working correctly.
There's just one thing now that I'm having difficulty with: As you said (and understandably), square reverts to undefined during calculation. I understand that I can use this.observableSquare.case({ pending: ... }) to specify an alternative intermediate value. But is there a simple way to just retain the old value of square while the promise is pending?
Yes.
this.observableSquare.case({
pending: () => this.oldValue,
rejected: e => this.oldValue,
fulfilled: v => this.oldValue = v
})
(oldValue doesn't need to be observable)
Sometimes it might be a good idea to indicate in the UI that the value is stale, or that the request is in progress, so thats why fromPromise defaults to asking the user to cover all cases for the general case...
Thank you, @spion!
Introducing a new property would certainly work. However, now we've got quite some overhead for a simple asynchronous dependency.
I found a package called computed-async-mobx. It's very similar to fromPromise, but has a number of options to determine how to deal with updates. The default mode is to keep the old value. This seems to be exactly what I've been looking for!
Using computed-async-mobx, my code gets shorter and more expressive (here's the fiddle):
class NumberInfo {
@observable value = 0;
observableSquare = computedAsync({
init: 0,
fetch: async () => {
const value = this.value;
await sleep(500);
return value * value;
}
});
@computed get square() {
return this.observableSquare.value;
}
}
Most helpful comment
Thank you, @spion!
Introducing a new property would certainly work. However, now we've got quite some overhead for a simple asynchronous dependency.
I found a package called computed-async-mobx. It's very similar to
fromPromise, but has a number of options to determine how to deal with updates. The default mode is to keep the old value. This seems to be exactly what I've been looking for!Using
computed-async-mobx, my code gets shorter and more expressive (here's the fiddle):