mobx.dart icon indicating copy to clipboard operation
mobx.dart copied to clipboard

Computed computes value without demand

Open Robbendebiene opened this issue 2 years ago • 4 comments

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 avatar Mar 31 '23 14:03 Robbendebiene

@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 avatar Apr 11 '23 10:04 amondnet

@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 avatar Apr 11 '23 11:04 Robbendebiene

@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

amondnet avatar Apr 12 '23 03:04 amondnet

Computed is recomputed when reaction checks for changes,

Ah, right I forgot about this. This makes sense.

So it works like this:

  1. flutter: recompute on 0 <<< initial call to (p0) => total.value
  2. flutter: recompute on 1 <<< caused by first change to observables because reaction needs to check if (p0) => total.value changed. It detects that it changed and "queues" the effect function
  3. flutter: recompute on 6 <<< reaction re-reads the current value of computed and passes it to the react function
  4. flutter: 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.

Robbendebiene avatar Apr 12 '23 08:04 Robbendebiene