riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

Missing documentation for how providers are compared / when they are updated

Open creativecreatorormaybenot opened this issue 4 years ago • 13 comments

Describe what scenario you think is uncovered by the existing examples/articles

I cannot find any documentation on when a Provider will notify about a new value.

Describe why existing examples/articles do not cover this case

I am wondering about this case in particular:

final myAwesomeProvider = Provider.autoDispose<List<int>>((ref) {
  final list = watch(fooProvider).list;
  return list;
});

I want to know if myAwesomeProvider will update dependants when the list object reference (identical) changes or when the iterable equality changes (DeepCollectionEquality/ListEquality).

Additional context

Will myAwesomeProvider notify dependants when the previous value was [1, 2, 3] (instance: 123aasd) and the new value is [1, 2, 3] (instance: 23wqej)?

And will it notify when the previous value was [1, 2, 3] (instance: askj) and the new value is [3, 1, 2] (instance: askj)?

@rrousselGit Also, this is something that I genuinely could not figure out.

I did not manage to find (did not search long enough probably) the code in Riverpod that notifies watchers about a Provider change. Would be great to know what equality operations are used (if it is just == or some custom handling for iterables e.g.).

@rrousselGit I suppose this would be the code: https://github.com/rrousselGit/river_pod/blob/97807e3ee1ed01a39047f3db78e43e1e829b82c4/packages/riverpod/lib/src/provider.dart#L224

If that is the case, there is no Iterable / general collection support. Would it make sense to create a PR for it or what is the recommended approach?

Should I just wrap my list in a class that overrides == to compare the lists properly?

Should I just wrap my list in a class that overrides == to compare the lists properly?

I'd suggest doing that

It used to have a deep comparison. But I removed it because in reality, new list instance means new content 99% of the time, so that was unnecessary computing

Alternatively you can make a PR that adds an "updateShouldNotify" to Provider

rrousselGit avatar Dec 09 '20 21:12 rrousselGit

Just as one data point, I was looking to use .select to bind to some filtered lists on the model, where a new list does not necessarily indicate new data:

  List<TodoItem> get completed => _all.where((item) => item.isCompleted == true).toList();
  List<TodoItem> get active => _all.where((item) => item.isCompleted == false).toList();

esDotDev avatar Oct 25 '21 20:10 esDotDev

The examples you gave (List filtering) would be better solved with a dedicated provider for this:

final completedTodos = Provider((ref) {
  final all = ref.watch(allTodos);
  return all.where((todo) => todo.isCompleted).toList();
})

The benefit is, this would cache the computation. So reading the provider twice will not filter the list twice.

rrousselGit avatar Oct 25 '21 21:10 rrousselGit

That's interesting, my worry with this approach is just scalability though like it seems like I'll end up with tons of providers, and they will all (potentially) reference each-other and it could be pretty spaghetti like, vs the clear encapsulation of just providing one model for each type of data, and that model deals with filtering/sorting whatever internally.

Thinking about it some more, I guess what I really don't like how it scatters the code around, .where((todo) => todo.isCompleted) seems like domain logic that fairly clearly belongs in the model, not inside of a provider somewhere.

esDotDev avatar Oct 25 '21 23:10 esDotDev

it seems like I'll end up with tons of providers, and they will all (potentially) reference each-other and it could be pretty spaghetti like

I would agree with you if not for a voluntary restriction: Circular dependencies aren't allowed.

It is technically impossible to make a spaghetti dependency graph because Riverpod forces uni-directional data flow.

What it ultimately boils down to is property vs provider. But I think declaring these kinds of providers as static final is a good pattern. Like:

class TodoItem {
  static final all = Provider(...)
 
  static final completedTodos = Provider(...);
}

rrousselGit avatar Oct 26 '21 00:10 rrousselGit

Ah I see, ya looking at it that way makes more sense... I need to maybe think of it as a model of providers, and not providing a single model.

esDotDev avatar Oct 26 '21 00:10 esDotDev

Since I'm still not totally sold on the trade-offs with these micro-providers, I ended up opting for the suggested approach, and making something like:

class EquatableList<T> with EquatableMixin {
  EquatableList(this.items);
  final List<T> items;

  @override
  List<Object?> get props => items;
}

Then I could have something like this, and bind to it efficiently:

EquatableList<TodoItem> get completed => EquatableList(_all.where((i) => i.isCompleted).toList());

I know this is a little wasteful due to the where calls, but hey, this is bread and butter for dart machine code :D

Rebuilds are working great:

https://user-images.githubusercontent.com/736973/138825016-a5afa72c-551e-4804-92d7-2e24e5f09f2c.mp4

esDotDev avatar Oct 26 '21 07:10 esDotDev

@rrousselGit I tried to do the static providers thing, but for some reason I'm still getting rebuilds in both of my consumers.

I have something like this:

class TodosController  extends ChangeNotifier {
  static final activeItemsProvider = Provider((ref) {
    final all = ref.watch(todosController).all;
    return all.where((i) => !i.isCompleted).toList();
  });

  static final completedItemsProvider = Provider((ref) {
    final all = ref.watch(todosController).all;
    return all.where((i) => i.isCompleted).toList();
  });
}

https://github.com/esDotDev/flutter_experiments/blob/master/riverpod_todo_with_changenotifier/lib/controller/todos_controller.dart

And I bind to them like:

Consumer(builder: (_, ref, __) {
  debugPrint("Build Active");
  List<TodoItem> items = ref.watch(TodosController.activeItemsProvider);
  return ListView(children: items.map((i) => TodoItemRenderer(i)).toList());
})),
...
Consumer(builder: (_, ref, __) {
  debugPrint("Build Completed");
  List<TodoItem> items = ref.watch(TodosController.completedItemsProvider);
  return ListView(children: items.map((i) => TodoItemRenderer(i)).toList());
}))

https://github.com/esDotDev/flutter_experiments/blob/master/riverpod_todo_with_changenotifier/lib/view/todos/todos_page.dart

But when I modify one of the lists, both consumers rebuild. Am I doing something wrong?

Using EquatableList, rebuilds are properly optimized:

EquatableList<TodoItem> get completed => EquatableList(_all.where((i) => i.isCompleted).toList());
EquatableList<TodoItem> get active => EquatableList(_all.where((i) => !i.isCompleted).toList());

If you check out the repo, it has both sets of code there and you can easily comment in the static provider method to test.

esDotDev avatar Oct 28 '21 17:10 esDotDev

Hum, you're correct. You'd need a List that implements == here

rrousselGit avatar Oct 30 '21 11:10 rrousselGit

Thanks! Haha ya I was scratching my head around how the providers could possibly work here...

Obviously we could cache the filtered lists if we needed too, but this seems like a nice easy to use approach for small-ish datasets.

fwiw, running a .where on 1million items is ~15ms on desktop and ~50ms on device. So anything under 10,000 items will not really be measurable at ~0.15 - 0.5ms.

esDotDev avatar Oct 30 '21 16:10 esDotDev

I was also looking for an explanation of when providers update their listeners in the docs.

ntc2 avatar Feb 19 '22 02:02 ntc2