Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Computed computes value without demand #912

Open
Robbendebiene opened this issue Mar 31, 2023 · 4 comments
Open

Computed computes value without demand #912

Robbendebiene opened this issue Mar 31, 2023 · 4 comments
Assignees
Labels
enhancement New feature or request question Further information is requested

Comments

@Robbendebiene
Copy link

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".

@amondnet
Copy link
Collaborator

amondnet commented Apr 11, 2023

@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 amondnet added the question Further information is requested label Apr 11, 2023
@Robbendebiene
Copy link
Author

@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?

@amondnet
Copy link
Collaborator

amondnet commented Apr 12, 2023

@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

@Robbendebiene
Copy link
Author

Robbendebiene commented Apr 12, 2023

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.

@amondnet amondnet added the enhancement New feature or request label Dec 19, 2023
@amondnet amondnet self-assigned this Dec 19, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants