Computed computes value without demand
I'm trying to use Computed for JSON serialization and automatic storage updates. It works perfectly. However my concern was that it might cause performance problems. I feared that the JSON would be computed for every single property change. So I did some testing which can be seen below. Fortunately MobX holds its promise and the JSON is not recreated for every single property change when reacting to it with a delay (which I guess is similar to debounced event listening).
I noticed though that it recreates the JSON object on the first modification, but I don't really understand why. Can someone please explain this to me? My goal is to only compute the JSON on demand (when a reaction "demands" the value).
Thanks for creating this great package!
Classes
class ChildClass {
final Observable<String> _name;
String get name => _name.value;
set name(String value) => _name.value = value;
ChildClass({
required String name,
}) : _name = Observable(name);
Map<String, dynamic> get asJson => observableJson.value;
late final observableJson = Computed<Map<String, dynamic>>(() {
print("recompute ChildClass $name");
return {
'name': name,
};
});
}
class ParentClass {
final Observable<String> _name;
final ObservableList<ChildClass> _elements;
String get name => _name.value;
set name(String value) => _name.value = value;
List<ChildClass> get elements => _elements;
ParentClass({
required String name,
Iterable<ChildClass> elements = const Iterable.empty(),
}) : _name = Observable(name),
_elements = ObservableList.of(elements);
Map<String, dynamic> get asJson => observableJson.value;
late final observableJson = Computed<Map<String, dynamic>>(() {
print("recompute ParentClass $name");
return {
'name': name,
'level': elements.map((e) => e.asJson).toList(),
};
});
}
void main() {
final parent = ParentClass(
name: "test",
elements: [
ChildClass(name: "first"),
],
);
reaction((p0) => parent.asJson, (v) {
print("log: $v");
}, delay: 1000);
print("> add second");
parent.elements.add(ChildClass(name: 'second'));
print("> add third");
parent.elements.add(ChildClass(name: 'third'));
print("> remove first");
parent.elements.removeAt(0);
print("> change first child name multiple times");
runInAction(() => parent.elements.first.name = "changed first 1");
runInAction(() => parent.elements.first.name = "changed first 2");
runInAction(() => parent.elements.first.name = "changed first 3");
runInAction(() => parent.elements.first.name = "changed first");
print("> change last child name");
runInAction(() => parent.elements.last.name = "changed last");
}
Console log
flutter: recompute ParentClass test
flutter: recompute ChildClass first
flutter: > add second
flutter: recompute ParentClass test
flutter: recompute ChildClass second
flutter: > add third
flutter: > remove first
flutter: > change first child name multiple times
flutter: > change last child name
flutter: recompute ParentClass test
flutter: recompute ChildClass changed first
flutter: recompute ChildClass changed last
flutter: log: {name: test, level: [{name: changed first}, {name: changed last}]}
The first and the last "recompute" seems reasonable to me (the last one is desired in my case and the first one is probably some initialisation). What I don't get is the "recompute" after "add second".
@Robbendebiene
The state of your application consists of core-state and derived-state. The core-state is state inherent to the domain you are dealing with. For example, if you have a Contact entity, the firstName and lastName form the core-state of Contact. However, fullName is derived-state, obtained by combining firstName and lastName.
Such derived state, which depends on core-state or other derived-state is called a Computed Observable. It is automatically kept in sync when its underlying observables change.
var x = Observable(10);
var y = Observable(10);
var total = Computed((){
return x.value + y.value;
});
x.value = 100; // recomputes total
y.value = 100; // recomputes total again
print('total = ${total.value}'); // prints "total = 200"
@amondnet Thanks for your reply. The documentation makes the distinction between core and derived state pretty clear. However your example claims that Mobx always recomputes values when any core state changes which is not correct.
If you run:
void main() {
var x = Observable(10);
var y = Observable(10);
var total = Computed((){
print("recompute");
return x.value + y.value;
});
x.value = 100;
y.value = 100;
print('total = ${total.value}');
}
You get:
flutter: recompute
flutter: total = 200
Because the Computed is only computed on "read" (last line with print in this case). This is a brilliant design. However for the example I provided in my question it doesn't work this way all the way through.
I can also demonstrate this with your example:
void main() {
var x = Observable(0);
var total = Computed((){
print("recompute on ${x.value}");
return x.value;
});
reaction((p0) => total.value, (v) {
print("reaction: $v");
}, delay: 1000);
runInAction(() => x.value = 1);
runInAction(() => x.value = 2);
runInAction(() => x.value = 3);
runInAction(() => x.value = 4);
runInAction(() => x.value = 5);
runInAction(() => x.value = 6);
}
Logs:
flutter: recompute on 0
flutter: recompute on 1
flutter: recompute on 6
flutter: reaction: 6
My question basically is why does it recompute for 1?
@Robbendebiene
This is because delay behaves as a debounce. (p0) => total.value is tracked, when it changes, the effect function(print("reaction: $v");) is executed. Computed is recomputed when reaction checks for changes, not when effect is executed.
import 'package:fake_async/fake_async.dart';
import 'package:mobx/mobx.dart';
import 'package:test/test.dart';
void main() async {
test('gh-912', () {
fakeAsync((async) {
var x = Observable(0);
var total = Computed(() {
print("recompute on ${x.value}");
return x.value;
});
final disposer = reaction((p0) => total.value, (v) {
print("reaction: $v");
}, delay: 1000);
runInAction(() => x.value = 1);
async.elapse(Duration(milliseconds: 500));
runInAction(() => x.value = 2);
async.elapse(Duration(milliseconds: 500));
runInAction(() => x.value = 3);
async.elapse(Duration(milliseconds: 500));
runInAction(() => x.value = 4);
async.elapse(Duration(milliseconds: 500));
runInAction(() => x.value = 5);
async.elapse(Duration(milliseconds: 500));
runInAction(() => x.value = 6);
async.elapse(Duration(milliseconds: 500));
disposer();
});
});
}
recompute on 0
recompute on 1
recompute on 2
reaction: 2
recompute on 3
recompute on 4
reaction: 4
recompute on 5
recompute on 6
reaction: 6
Computed is recomputed when reaction checks for changes,
Ah, right I forgot about this. This makes sense.
So it works like this:
flutter: recompute on 0<<< initial call to(p0) => total.valueflutter: recompute on 1<<< caused by first change to observables becausereactionneeds to check if(p0) => total.valuechanged. It detects that it changed and "queues" the effect functionflutter: recompute on 6<<< reaction re-reads the current value of computed and passes it to the react functionflutter: reaction: 6
Thank you!
So I thought I can prevent this (in my case) unintended recompute by simply using autorun. However it has a similar effect:
void main() async {
var x = Observable(0);
var total = Computed((){
print("recompute on ${x.value}");
return x.value;
});
autorun((_) {
print("reaction: ${total.value}");
}, delay: 1000);
runInAction(() => x.value = 1);
runInAction(() => x.value = 2);
runInAction(() => x.value = 3);
await Future.delayed(Duration(seconds: 2));
runInAction(() => x.value = 4);
runInAction(() => x.value = 5);
runInAction(() => x.value = 6);
}
flutter: recompute on 3
flutter: reaction: 3
flutter: recompute on 4 <<<< unintended
flutter: recompute on 6
flutter: reaction: 6
I guess similar to the first case flutter: recompute on 4 is called because when the underlying observable changes somewhere internally the autorun Reaction reads the computed value to "queue" its effect function. But why does it read the value? Simply knowing that the computed changed should be enough to schedule the autorun effect function.
I feel like it must be possible to somehow circumvent this recomputation of 4. Any help is greatly appreciated.