rrousselGit / riverpod

A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze.
https://riverpod.dev
MIT License
6.28k stars 955 forks source link

[RFC] Unifying syntax for listening to providers (v2) #335

Closed rrousselGit closed 3 years ago

rrousselGit commented 3 years ago

This RFC is a follow-up to https://github.com/rrousselGit/river_pod/issues/246 with a slightly different proposal.

The problems are the same:

See https://github.com/rrousselGit/river_pod/issues/246 for a bit more explanation

Proposal

Instead of passing directly "watch" as parameter to widgets, Riverpod could do like with its providers and pass a "ref" object"

As such, instead of:

class StatelessExample extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    A value = watch(a);
  }
}

we'd have:

class StatelessExample extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetReference ref) {
    A value = ref.watch(a);
  }
}

Similarly, Consumer would become:

Consumer(
  builder: (context, ref, child) {
    A value = ref.watch(a);
  },
);

The behaviour would be strictly identical. But this then allows Riverpod to add extra methods on WidgetsReference, which could allow:

class StatelessExample extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetReference ref) {
    ref.listen<A>(a, (value) {
      print('provider a changed $value');
    });
  }
}

This would be equivalent to ProviderListener but without involving nesting.

Hooks consideration

While hooks_riverpod doesn't suffer from the problem listed at the start of the issue, the logic wants that hooks_riverpod should also use the same syntax too (both to reduce confusion and simplify maintenance).

As such, useProvider would be deprecated and a ConsumerHookWidget would be introduced. Which means that instead of:

class HooksExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    A value = useProvider(a);
  }
}

we'd have:

class HooksExample extends ConsumerHookWidget {
  @override
  Widget build(BuildContext context, WidgetReference ref) {
    A value = ref.watch(a);
  }
}

This would also clarify that the only purpose of hooks_riverpod is to use both hooks and Riverpod simultaneously.

context.read/context.refresh considerations

context.read(myProvider) and context.refresh(provider) would be deprecated.

Instead, ref should now be used. So the previous:

class StatelessExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => context.read(counterProvider).state++;
    );
  }
}

would become:

class StatelessExample extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetReference ref) {
    return ElevatedButton(
      onPressed: () => ref.read(counterProvider).state++;
    );
  }
}

(and same thing with refresh)

This has two side-effects:

StatefulWidget consideration

An optional goal of this change is to support StatefulWidget.

This could be done by shipping a State mixin that adds a ref property, which would allow us to write:

class StatefulExample extends StatefulWidget {
   @override
  _StatefulExampleState createState() => _StatefulExampleState();
}

class _StatefulExampleState extends State<StatefulExample> with ConsumerStateMixin {
  @override
  Widget build(BuildContext context) {
    A value = ref.watch(a);
  }
}

Note that this is entirely optional, as Consumer can already be used.

ProviderReference vs WidgetReference

While the syntax for listening a provider in widgets and providers would now look similar with both being ref.watch(myProvider), it is important to note that ProviderReference and WidgetReference are distinct objects.

They are not interchangeable, and you could not assign WidgetReference to ProviderReference for example.

Their main difference is, ProviderReference does not allow interacting with ScopedProviders. On the other hand, WidgetReference do.

Similarly, it is entirely possible that in the future, some functionalities as added to one without being added to the other (such as https://github.com/rrousselGit/river_pod/pull/302 which allows ProviderReference to manipulate the state of its provider)

Conclusion

That's it, thanks for reading!

As opposed to https://github.com/rrousselGit/river_pod/issues/246, one major difference is that this proposal is "compile safe" without having to rely on a custom linter.
The downside is that the syntax for reading providers becomes a tiny bit more verbose.

What do you think of the proposed change?

Feel free to leave a comment. You can also use :+1: and :-1: to express your opinion. All feedbacks are welcomed!

jeiea commented 3 years ago

That shouldn't lock you into using Riverpod forever. If it takes 2 seconds to switch from StatelessWidget to ConsumerWidget, it also takes 2 seconds to do it the other way around.

I think it's hard to migrate hooks in automated way. Well it may be a special case of me, but I can't see contagious parameter insertion is trivial.

The difference in verbosity is minimal.

I don't think you didn't consider this, therefore I'm curious why there's disagreement in this issue.

useA() { useB(); }
useB() { useC(); }
useC() { useD(); }
useD() { useE(); }
useE() { useRepository(); }

The above will be

useA(ref) { useB(ref); }
useB(ref) { useC(ref); }
useC(ref) { useD(ref); }
useD(ref) { useE(ref); }
useE(ref) { useRepository(ref); }

It's similar to prop drilling. I think it is one of major reason of using DI library. If it's fine then why not use plain Map?

scopendo commented 3 years ago

It does seem a shame for idiomatic Flutter development that it's not possible to obtain a provider's value using an of(context) method.

final Counter = Provider(...); final counter = Counter.of(context);

And if such we're possible then presumably there'd be no need for passing the WidgetRef to hooks.

Appreciate that may be much easier said than done.

On Thu, 16 Sep 2021, 07:50 jeiea, @.***> wrote:

That shouldn't lock you into using Riverpod forever. If it takes 2 seconds to switch from StatelessWidget to ConsumerWidget, it also takes 2 seconds to do it the other way around.

I think it's hard https://github.com/rrousselGit/river_pod/issues/335#issuecomment-869850461 to migrate hooks in automated way. Well it may be a special case of me, but I can't see contagious parameter insertion is trivial.

The difference in verbosity is minimal.

I don't think you didn't consider this, therefore I'm curious why there's disagreement in this issue.

useA() { useB(); } useB() { useC(); } useC() { useD(); } useD() { useE(); } useE() { useRepository(); }

The above will be

useA(ref) { useB(ref); } useB(ref) { useC(ref); } useC(ref) { useD(ref); } useD(ref) { useE(ref); } useE(ref) { useRepository(ref); }

It's similar to prop drilling. I think it is one of major reason of using DI library. If it's fine then why not use plain Map?

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/rrousselGit/river_pod/issues/335#issuecomment-920626096, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAE6N6USYNEHBWS6GILEE7DUCGHU3ANCNFSM4X4F26PQ . Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.

satvikpendem commented 3 years ago

Would functional_widget be updated to include ConsumerWidget/HookConsumerWidget? I previously used @hwidget with useProvider and it worked pretty well, cut down on a lot of boilerplate.

venkatd commented 3 years ago

I wonder how many feel that useProvider is simpler because they are used to it. A few months into the new syntax and I find the unification more intuitive.

I only have to understand ref.watch and ref.read rather than useProvider and ref.watch. It's one less concept I have to wrap my head around and it's the same amount of typing.

In many cases, we've got rid of hooks as they don't add much value vs. just calling ref.watch directly. For example, we had a useApi() which then became ref.watch(apiProvider). It's about the same amount of typing, but more explicit.

Aside from having to pass ref around in some cases, are there any other objections to the new syntax?

rrousselGit commented 3 years ago

Note that one thing I'm hopeful for is, once we have metaprogramming, we may be able to straight up remove ConsumerWidget/ConsumerHooKWidget and do:

final labelProvider = Provider((ref) => 'Hello world');

class Example extends StatelessWidget {
  @override
  Widget build(context) {
    final message = @watch(labelProvider);
    return Text(message);
  }
}

And it likely would work within hooks too.

Still, we'll see if the Dart team will really allow such macros.

debkanchan commented 3 years ago

I just read that and orgasmed. We want static metaprogramming so bad.

creativecreatorormaybenot commented 3 years ago

I just read that and orgasmed. We want static metaprogramming so bad.

I do not like it that much - it surely makes the code less readable / more difficult to understand / to dive into as you cannot easily view the source.

It really is not any effort to write out ConsumerWidget.

rrousselGit commented 3 years ago

To be clear, using metaprogramming here isn't about reducing a bit the numbers of characters we have to type.

The primary use-cases are:

In particular, a common problem with the v1, where we can now scope all providers, is that folks tend to override a provider but forget to override its dependencies. Such that we have:

final provider = Provider((ref) => 'Hello')
final another = Provider((ref) => ref.watch(provider))

ProviderScope(
  overrides: [
    provider.overrideWithValue('Bonjour'),
  ],
  child: Consumer(
    builder: (context, ref, _) => Text(ref.watch(another)) // will print Hello, not Bonjour
  )
)

The fix is to do:

ProviderScope(
  overrides: [
    provider.overrideWithValue('Bonjour'),
    another, // tell Riverpod to scope the 'another' provider too
  ],

But it seem confusing.

So one solution I have in mind, providers could list their dependencies like so:

final provider = Provider((ref) => 'Hello')
final another = Provider((ref) => ref.watch(provider), dependencies: {provider});

such that when we override provider, this will override another too. But having to specify the dependencies is tedious.

Hence the idea of using metaprogramming here. Where folks would write:

final provider = Provider((ref) => 'Hello')
final another = Provider((ref) => @watch(provider));

and that dependencies: {provider} would be generated by the macro for you. That should fully fix the problem.

pikaju commented 3 years ago

Please take people's criticism seriously, Remi. I agree with all the criticism mentioned so far.

I would also like to put more emphasis on the argument of @scopendo, in that it would be great to use the context object for retrieval, the same way it works with the provider package. What exactly is the motivation for not using something along the lines of context.watch(counterProvider)? The syntax would be the same, except for the word context, but one could argue that this would justify renaming the Provider construction's parameter ref to context. This would also solve the problem with flutter_hooks, as it would be possible to write useContext().watch(counterProvider). And it makes sense in cases where only a context but no ref is available. Also you don't need to change the widget base class (big win). In general it seems extremely convenient, and removes the need for metaprogramming in the build method.

I'm aware you mentioned that this is not feasible, perhaps I just misunderstood the architecture, could you elaborate further? I'd imagine if provider can do it, so can riverpod? This being a widely used package, I would personally place the importance of a clean way to use the package over how clean the implementation is.

I clearly see the advantages of riverpod over provider, but with the way things are moving now I would begrudgingly switch back.

rrousselGit commented 3 years ago

I'm aware you mentioned that this is not feasible, perhaps I just misunderstood the architecture, could you elaborate further? I'd imagine if provider can do it, so can riverpod?

One of the very reason Riverpod is a separate package from Provider is because context.watch isn't reasonably doable.

context.watch works by using InheritedWidgets to rebuild widgets. ref.watch isn't relying on InheritedWidgets, but rather a combination of "addListener" + "setState".
This is a necessary requirement for various reasons, be it fixing bugs with InheritedWidgets or adding support for new features or performance.

Using context would require:

In comparison, using ConsumerWidget instead of StatelessWidget is a benign change.

TimWhiting commented 3 years ago

@pikaju Seeing as it is infeasible to go back to the 'context' way of doing things, is there anything that we can do to make the transition easier for you? For example we have the riverpod_cli package that provides a migration tool to migrate automatically from the pre-1.0.0 riverpod to the latest dev version.

Additionally I'm interested in helping Remi with an analyzer plugin for riverpod that will likely include refactorings like the 'Wrap with widget' and 'Extract Widget' refactorings that are available from flutter, for example we could do:

Also I've discussed with Remi how to provide a migration tool from the old provider package to the riverpod package for legacy codebases.

So if there is any change that you think would improve the experience of riverpod over provider without going back to the limitations of provider, I'm sure the suggestions would be welcome.

venkatd commented 3 years ago

@pikaju criticism is taken into consideration, but there are many different opinions on how riverpod should be designed, and the and some some difficult tradeoffs have to be considered.

I personally love that riverpod is not coupled to BuildContext or Flutter at all. This makes testing pure Dart code much easier and would allow reuse of riverpod in other contexts such as AngularDart.

Changing the base class is very fast. You have to to add/remove Consumer or HookConsumer and add/remove the WidgetRef parameter. It takes less than 5 seconds.

The main criticism that might be valid is that, if a hook depends on ref, that parameter would now need to be drilled through all the hooks that need it. But again, it was a tradeoff that had to be made. I personally prefer passing this along since it's a more explicit dependency.

In our codebase, hooks tend to be reserved for more general purpose functions for state management.

Perhaps you could share some code samples of where you find your developer experience is worse with the new riverpod syntax? Without something specific, it will be hard to provide suggestions or find ways to improve riverpod.

pikaju commented 3 years ago

Thank you for the clarification @rrousselGit, I think I understand the problem in much more detail now. I also took a look at the flutter_riverpod, hooks_riverpod, flutter_hooks, and even the Flutter framework source code to see how things currently work under the hood, and spent a lot of time trying to think of possible improvements. I admit it is very tough. Prepare for a lot of rambling.

My main goal was to apply the "composition over inheritance" rule to both flutter_riverpod as well as flutter_hooks. Turning ConsumerState into a ConsumerStateMixin and simply not passing the ref into the build function. I honestly prefer that over a new base class with new parameter sets, but it doesn't change much, and doesn't solve the problem of having an XxxYyyZzzWidget in hooks_riverpod, as @PiN73 called it, or in this case, an XxxYyyZzzWidgetMixin. So I thought about changing flutter_hooks to have a HookWidgetMixin instead of a HookWidget (bear with me). Unfortunately, that does not work because both packages replace the default Element types with their own subtypes to implement their special behavior, making them somewhat mutually exclusive by nature, which, like you, I also didn't find a way around. I thought about creating a new package containing a compositional Element type that packages like flutter_hooks and flutter_riverpod can depend on in order to harmonize with one another, but this is assuming there is a need for more widgets of this style.

Eventually I thought it would be better to just create a subtype of InheritedWidget that is capable of detecting when a listener is removed, so that we can roll with context.watch, but when I saw how the Flutter framework implements dependency removal, I flipped my table. Perhaps some Flutter pull requests would help to go the context.watch route, what do you think?

TLDR: You guys did the best you could, I surrender. All hail our lord and savior, metaprogramming. If at all possible, please try adding useWidgetRef.

@TimWhiting I appreciate the effort, but I'm personally not a big fan of automatic code conversion tools. @venkatd Keep in mind that flutter_riverpod is already detached from riverpod. I'm only looking for ways to improve the Flutter/hooks bindings.

rrousselGit commented 3 years ago

Believe me, I spent a lot of time trying to get BuildContext to work. I'm open to suggestions, but I haven't found any approach that wouldn't have fundamental flaws.

And that includes making PRs in Flutter to change how InheritedWidgets works. I made a few experiments before Riverpod was a thing, with no success.

Ultimately while it's a bit sad to have to rely on ConsumerWidget, I don't see that as a real issue. Flutter already comes with a lot of Widget type; far more than 3 that is. Adding one or two more seem innocent to me.


I wouldn't place too much hope in metaprogramming though.

While I would love to be able to do final value = @watch(provider) and remove ConsumerWidget/HookWidget, the Dart team doesn't seem too interested in the use-case.
They haven't straight-up rejected it, but I highly doubt that the initial release will allow us to do that.

rrousselGit commented 3 years ago

Time to finally close this issue. Thanks for everyone's participation!