Define a computed by returning a Promise
"People starting with MobX tend to use reactions [autorun] too often. The golden rule is: if you want to create a value based on the current state, use computed." - MobX - Concepts & Principles
A computed in MobX is defined by a function, which consumes other observable values and is automatically re-evaluated, like a spreadsheet cell containing a calculation.
@computed get creditScore() {
return this.scoresByUser[this.userName];
}
However, it has to be a synchronous function body. What if you want to do something asynchronous? e.g. get something from the server. That's where this little extension comes in:
creditScore = computedAsync(0, async () => {
const response = await fetch(`users/${this.userName}/score`);
const data = await response.json();
return data.score;
});
Further explanation, rationale, etc.
npm install computed-async-mobx
Of course TypeScript is optional; like a lot of libraries these days, this is a JavaScript library that happens to be written in TypeScript. It also has built-in type definitions: no need to npm install @types/... anything.
I first saw this idea on the Knockout.js wiki about five years ago. As discussed here it was tricky to make it well-behaved re: memory leaks for a few years.
MobX uses the same (i.e. correct) approach as ko.pureComputed from the ground up, and the Atom class makes it easy to detect when your data transitions between being observed and not.
Also a 🌹 for Basarat for pointing out the need to support strict mode!
Unlike the normal computed feature, computedAsync can't work as a decorator on a property getter. This is because it changes the type of the return value from PromiseLike<T> to T.
Instead, as in the example above, declare an ordinary property. If you're using TypeScript (or an ES6 transpiler with equivalent support for classes) then you can declare and initialise the property in a class in one statement:
class Person {
@observable userName: string;
creditScore = computedAsync(0, async () => {
const response = await fetch(`users/${this.userName}/score`);
const data = await response.json();
return data.score; // score between 0 and 1000
});
@computed
get percentage() {
return Math.round(this.creditScore.value / 10);
}
}Note how we can consume the value via the .value property inside another (ordinary) computed and it too will re-evaluate when the score updates.
This library is transparent with respect to MobX's strict mode. Like computed, it doesn't mutate state but only consumes it.
Take care when using async/await. MobX dependency tracking can only detect you reading data in the first "chunk" of a function containing awaits. It's okay to read data in the expression passed to await (as in the above example) because that is evaluated before being passed to the first await. But after execution "returns" from the first await the context is different and MobX doesn't track further reads.
The API is presented here in TypeScript but (as always) this does not mean you have to use it via TypeScript (just ignore the <T>s and other type annotations...)
The type returned by the computedAsync function. Represents the current value. Accessing the value inside a reaction will automatically listen to it, just like an observable or computed. The busy property is true when the asynchronous function is currently running.
interface ComputedAsyncValue<T> {
readonly value: T;
readonly busy: boolean;
readonly failed: boolean;
readonly error: any;
}If the current promise was rejected, failed will be true and error will contain the rejection value (ideally this would be based on Error but the Promise spec doesn't require it).
Accepted by one of the overloads of computedAsync.
init- value used initially, and when not being observedfetch- the function that returns a promise or a plain value, re-evaluated automatically whenever its dependencies change. Only executed when thecomputedAsyncis being observed.delay- milliseconds to wait before re-evaluating, as in autorunAsyncrevert- if true, the value reverts toinitwhenever thefetchfunction is busy executing (you can use this to substitute "Please wait" etc.) The default isfalse, where the most recent value persists until a new one is available.name- debug name for Atom used internally.error- if specified and a promise is rejected, this function is used to convert the rejection value into a stand-in for the result value. This allows consumers to ignore thefailedanderrorproperties and observevaluealone.rethrow- if true andvalueis access in thefailstate, theerroris rethrown.
interface ComputedAsyncOptions<T> {
readonly init: T;
readonly fetch: () => PromiseLike<T> | T;
readonly delay?: number;
readonly revert?: boolean;
readonly name?: string;
readonly error?: (error: any) => T,
readonly rethrow: boolean;
}Overload that takes most commonly used options:
function computedAsync<T>(init: T, fetch: () => PromiseLike<T>, delay?: number): ComputedAsyncValue<T>;This is equivalent to calling the second overload (below): computedAsync({ init, fetch, delay }).
function computedAsync<T>(options: ComputedAsyncOptions<T>): ComputedAsyncValue<T>;See CHANGES.md.
MIT, see LICENSE