rrousselGit / riverpod

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

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

Closed rrousselGit closed 2 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!

dhannie-k commented 3 years ago

Easier to read

RobertBrunhage commented 3 years ago

I like most of it but I am not sure that I like the approach of a new ConsumerHookWidget.

When working with hooks we have a "pre-understanding" that we can use the useSomething statement to accomplish what we want, but the ConsumerHookWidget implements a complete new class just for this.

I don't really see how the benefits outweighing the negatives in this one. To me this would be a lot more verbose because we would have to refactor a HookWidget to a ConsumerHookWidget just to use the providers.

davidmartos96 commented 3 years ago

Following what @RobertBrunhage just said. If I'm not mistaken, the hook change would also mean that custom hooks that read from a provider now would need to receive a WidgetReference as a parameter.

erf commented 3 years ago

I like the new API !

I'm not familiar with riverpod_hooks, but i'm a bit confused that there are two separate packages / API's built on riverpod. Can someone explain why we need both riverpod_flutter and riverpod_hooks?

Also why have a ConsumerWidget / ConsumerHookWidget when you could just use a Consumer? I think removing the first two widgets would simplify the API and make it easier to get into.

rrousselGit commented 3 years ago

The ConsumerHookWidget is mainly for maintainability, to avoid having to duplicate work.

The issue with the current approach is, if I add a ref.listen, then I need to add a hook for it.

gaetschwartz commented 3 years ago

I honestly like the proposal, only thing I don't really like is the necessity to use ConsumerWidget instead of StatelessWidget. For StatefulWidgets it's okay as adding a mixin is an easy migration but having to rewrite all StatelessWidgets as ConsumerWidgets is going to be tedious. Would it be possible to have something like a ConsumerStateMixin but for StatelessWidget ?

rrousselGit commented 3 years ago

No, a mixin on StatelessWidget is not feasible.

rrousselGit commented 3 years ago

@gaetschwartz do you have an example of what would he tedious to update?

Depending on the code, it may be doable to implement a migration tool. No promise though

rydmike commented 3 years ago

This is quite a big breaking change imo. Then again Riverpod is still a 0.x.x version which basically includes that anything still goes from API dev point of view as well. Still for those already using Riverpod a lot of code will need to be changed, trivial refactors, but still quite a bit.

I actually don't like the new syntax that much, it is more verbose and not as pretty as before. Applies to the watch and especially hooks. Read is basically equivalent, so no real diff, but a refactor as well.

I like what this enables and appreciate that part, but it is not as pretty as before and anybody that has been using Riverpod will have quite a bit of work ahead of them to migrate to this new syntax.

On the plus side (in addition to the listeners) it does make things more consistent at the same time, which probably is less confusing for newcomers, so that is probably a good thing.

gaetschwartz commented 3 years ago

Just saying that imagine having the most simple StatelessWidget:

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return TextButton(
        onPressed: () {
          context.read(myProvider).doStuff();
        },
        child: const Text('Tap me!'));
  }
}

You have to:

  1. Replace StatelessWidget by ConsumerWidget - Feasible by just replacing all strings "StatelessWidget" by "ConsumerWidget".
  2. Change the signature of build - Can be a little longer but can also be done using replace and Regex.
  3. Replace all context.read() by ref.read(), the first - Can also be done by replacing "context.read" by "ref.read".

These changes can be trivial to advanced developers by using replace string/regex in VSCode etc but it is probably going to be tedious for beginners/newcomers. Providing instructions to semi-automatically migrate using regexp and search & replace on VSCode would be a good idea.

Tldr; if you are to make these changes it would be great to either give proper tools to migrate or make it easier to do so because as it is it's too much of a breaking change and would make it a long migration for middle/large sized projects. Where most users wouldn't actually see an improvement to their usage of riverpod so the cost/benefit wouldn't be optimal.

RobertBrunhage commented 3 years ago

The ConsumerHookWidget is mainly for maintainability, to avoid having to duplicate work.

The issue with the current approach is, if I add a ref.listen, then I need to add a hook for it.

Have nothing against the maintainability part but I am afraid that in my point of view this will only make Hooks more complicated to get in to for newcomers.

Regarding what others have said about refactoring I agree that it will be a bit of a pain point but we also have to be aware that this is stated in the Readme file The API may change slightly when more features are added, and is something we should come to expect.

Personally I don't want to limit the refactoring part of making Riverpod easier to use before it hits 1.0.0 as that is when it will be harder to change these core implementations.

rrousselGit commented 3 years ago

About readability, it's worth noting that as Dart evolves, the syntax will likely get better naturally

For example, object destructuring could be a candidate:

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

I agree with @RobertBrunhage on the last days I've been sharing content about Riverpod as a great tool, but people get afraid to fast. I think this change will only be a new tiny thing on the learning curve that riverpod brings with it.

esenmx commented 3 years ago

The only concern I have, since there is no real difference between context.read/refresh and ref.read/refresh(I think the only difference is ref lookup delay, which is not even a minor issue), context.read/refresh should be stayed as optional way. Depreciating it seems pointless to me.

ToniTornado commented 3 years ago

I like the proposal. Even for large projects it shouldn‘t be too much effort to migrate by updating Riverpod and just quickly fixing the Analyzer issues. I share the thoughts of @RobertBrunhage on ConsumerHookWidget but I can accept that for the sake of consistency and having a simpler API for beginners who probably start using Riverpod without hooks.

rrousselGit commented 3 years ago

On the plus side for hooks, ConsumerHookWidget allows conditions/loops:

Column(
  children: [
    if (something)
      Text(ref.watch(provider)),
    Text('Hello world'),
  ],
)  

This is not something doable with useProvider

Marco87Developer commented 3 years ago

I don’t understand something. If the syntax using hooks becomes the same as it would if we didn’t use hooks, what would be the added value of hooks?

rrousselGit commented 3 years ago

what would be the added value of hooks?

That you get to use hooks

So you can do:

class HooksExample extends ConsumerHookWidget {
  @override
  Widget build(BuildContext context, WidgetReference ref) {
    A value = ref.watch(a);
    final controller = useTextEditingController();
  }
}
dancamdev commented 3 years ago

As a user of hooks_riverpod, I’m a bit concerned about the ConsumerHookWidget just like @RobertBrunhage said.

if the reason for it all is to have a WidgetReference wouldn’t it be possible to return it through an hook:

Widget build(BuildContext context) {
 final ref = useProviderRef()
 final provider = ref.watch(a);
}

and have a plain HookWidget

this would also allow for conditionals and loops

TimWhiting commented 3 years ago

I am in agreement with everyone. I share some concerns over deprecating useProvider. Mainly for reasons of creating / composing custom hooks that use providers. I think that @dancamdev's proposal of having a hook that returns a WidgetReference would be a good one if possible. However I also think that it would be nice to have the extra parameter in the build method to allow reading / watching in loops / conditionals, with fewer lines of code. I also like that it makes it easier to refactor between ConsumerWidget and ConsumerHookWidget, possibly an analyzer plugin might be able to add those refactorings too.

// In a custom hook
X useProviderXListenAndShowSnackbar() {
  final ref = useWidgetsReference();
  final context = useContext();
  final X = ref.listen(providerOfX, () {
    if (X.someCondition) {
      ScaffoldMessenger.of(context).showSnackBar(SnackBar());
    }
  });
  return X;
}

// Inside ConsumerHookWidget
Widget build(BuildContext context, WidgetReference ref) {
 final state = ref.watch(provider);
}

The main other complain I'm hearing is the tediousness of migration. I'm willing to help create a migration tool. I'll open another issue to talk about a migration tool.

dancamdev commented 3 years ago

@TimWhiting Just a clarification, I'm most probably wrong, as I often am, but by returning the reference through a hook, you should be free to call ref.watch(provider) in any conditional or loop. Again I'm no expert in how hooks work under the hood, so I may be wrong

TimWhiting commented 3 years ago

@dancamdev I'm not saying that you can't do it exactly your suggested way. In fact you can and you should be able to use the WidgetReference in a loop just as you said.

I'm just saying that adding an extra parameter in the build method takes up fewer lines of code and makes the api more consistent allowing for easier refactorings between ConsumerHookWidget and ConsumerWidget for when you don't need hooks. Having the api more consistent also makes the documentation simpler.

But I agree that we need a hook for getting a WidgetReference for when you are not in the build method. Either way you should be able to use the WidgetReference in a loop. Unless I'm wrong, and it's not possible to get a hook to do this? Remi can correct me.

symeonoff commented 3 years ago
final myProvider = StateProvider<bool>((ProviderContext context) {
  final another = context.read(anotherProvider);
  final oneMoreProvider = context.watch(oneMoreProvider);
});

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final another = context.read(anotherProvider);

    return Text(anotherProvider);
  }
}

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer(builder: (context, watch, _) {
    final oneMoreProvider = context.watch(oneMoreProvider);

    return Text(oneMoreProvider);
    })
    ;
  }
}

class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context) {
    final oneMoreProvider = context.watch(oneMoreProvider);

    return Text(oneMoreProvider);
  }
}

class MyWidget extends ConsumerHookWidget {
  @override
  Widget build(BuildContext context) {
    final oneMoreProvider = context.watch(oneMoreProvider);

    return Text(oneMoreProvider);
  }
}

class MyWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final oneMoreProvider = context.watch(oneMoreProvider);

    return Text(oneMoreProvider);
  }

Everything is pretty much self explanatory. This way you can have context.container, context.refresh and etc.

rrousselGit commented 3 years ago

Everything is pretty much self explanatory. This way you can have context.container, context.refresh and etc.

context.watch is not feasible.

Marco87Developer commented 3 years ago

On the plus side for hooks, ConsumerHookWidget allows conditions/loops:

Column(
  children: [
    if (something)
      Text(ref.watch(provider)),
    Text('Hello world'),
  ],
)  

This is not something doable with useProvider

With this and with the answer you gave to my question, @rrousselGit, I declare myself in favor of the proposal! 😁

scopendo commented 3 years ago

Generally, I think this is positive and is an improvement over the previous RFC. I agree with ConsumerHookWidget but also agree with the suggestion from @dancamdev for a useProviderRef hook for use inside of a HookWidget.

Can I suggest that the following name changes:

There's a slight downside to the additional verbosity in consumer widgets and also that methods might now need to be passed both a build context and the proposed widget reference – I use private methods to avoid an unwieldy build method. I think this downside is worth it.

Would it be possible to get the reference from a context? Not sure if that would have the same issue as previously with not being able to have a context.watch method.

Marco87Developer commented 3 years ago

As a user of hooks_riverpod, I’m a bit concerned about the ConsumerHookWidget just like @RobertBrunhage said.

if the reason for it all is to have a WidgetReference wouldn’t it be possible to return it through an hook:

Widget build(BuildContext context) {
 final ref = useProviderRef()
 final provider = ref.watch(a);
}

and have a plain HookWidget

this would also allow for conditionals and loops

I also agree with @dancamdev regarding useProviderRef.

nank1ro commented 3 years ago

I like almost everything except the necessity to use a ConsumerWidget to read a provider in a callback of a StatelessWidget.

onPressed: () => ref.read(counterProvider).state++;

In the Consumer widget documentation you say:

[ConsumerWidget], a base-class for widgets that wants to listen to providers.

But in this case, we don't want to listen to nothing, we are simply incrementing the state.

rrousselGit commented 3 years ago

I'm against useProviderRef for technical reasons: Implementing it is basically no different from keeping useProvider and adding a useProviderListener

So it defeats the point of having a ConsumerHookWidget to remove code duplicates

scopendo commented 3 years ago

I like almost everything except the necessity to use a ConsumerWidget to read a provider in a callback of a StatelessWidget.

onPressed: () => ref.read(counterProvider).state++;

In the Consumer widget documentation you say:

[ConsumerWidget], a base-class for widgets that wants to listen to providers.

But in this case, we don't want to listen to nothing, we are simply calling a function.

Note that this does not work if the provider is auto-disposed – you have to add a dummy watch in the build method if there might not be any other references.

rrousselGit commented 3 years ago

About ref.read instead of context.read, one thing that this would allow is:

ref.read would allow Riverpod to keep the provider obtained "alive" until the widget that called ref.read is destroyed.
This is a common source of confusion with context.read, but context.read cannot maintain the state of providers because of the context API.

This is a separate issue though. And if we do that, ProviderReference.read should be updated to also maintain the state of providers (it doesn't at the moment to behave the same way than context.read to not confuse people)

scopendo commented 3 years ago

I'm against useProviderRef for technical reasons: Implementing it is basically no different from keeping useProvider and adding a useProviderListener

So it defeats the point of having a ConsumerHookWidget to remove code duplicates

I see ConsumerHookWidget as allowing for standardisation of how to watch and read a provider's value. However, the useProvider hook is useful for hook composition, otherwise, we'd have to pass the reference in, which starts to get ugly.

rrousselGit commented 3 years ago

I personally don't see the issue with:

T useMyProvider(ConsumerReference ref) {
  final value = ref.watch(myProvider);
  return value;
}

class Example extends ConsumerHookWidget {
  @override
  Widget build(context, ref) {
    final value = useMyProvider(ref);
  }
}
dancamdev commented 3 years ago

I personally don't see the issue with:


T useMyProvider(ConsumerReference ref) {

  final value = ref.watch(myProvider);

  return value;

}

class Example extends ConsumerHookWidget {

  @override

  Widget build(context, ref) {

    final value = useMyProvider(ref);

  }

}

My point in using the useProviderRef() hook would be to keep extending the plain HookWidget, so there's no need to differentiate wether a Widget will be using a Provider or not. But I'm missing the whole picture probably, I'm not getting why this ConsumerHookWidget + ref passed to the build method is such an improvement.

TimWhiting commented 3 years ago

I'm against useProviderRef for technical reasons: Implementing it is basically no different from keeping useProvider and adding a useProviderListener

So it defeats the point of having a ConsumerHookWidget to remove code duplicates

@rrousselGit Trying to understand.

From a code duplication standpoint I understand you don't want to both. And I'm still okay with the proposal if we don't get a useProviderRef() hook if it is going to cause bugs / problems with maintaining two implementations.

But could you not do something like this?: Implement useProviderRef() which returns a WidgetReference or whatever it will be called, I like the proposal above to call it ConsumerReference.

Then create ConsumerHookWidget building off of useProviderRef (not duplicating the code behind it):


abstract class ConsumerHookWidget extends StatefulWidget {
  const ConsumerHookWidget({Key? key}) : super(key: key);

  Widget build(BuildContext context, ConsumerReference ref);

  @override
  _ConsumerHookWidgetState createState() => _ConsumerHookWidgetState();
}

class _ConsumerHookWidgetState extends State<ConsumerHookWidget> {
  @override
  Widget build(BuildContext context) {
    return HookBuilder((context) {
      final consumerRef = useProviderRef();
      return widget.build(context, consumerRef);
    });
  }
}

Apologies if I'm oversimplifying this or don't understand how Hooks and Riverpod is implemented.

I understand that this will still mean that for Hook's users there are two different ways to accomplish the same thing, which might be confusing to new users, so I'm fine if this isn't what ends up happening.

rrousselGit commented 3 years ago

The issue is not about ConsumerHookWidget vs useConsumerReference

It's about ConsumerWidget vs useConsumerReference.

ConsumerWidget is unaware of hooks. As such, it cannot use useConsumerReference. Similarly, there's no way to share code between them, since the life-cycles of hooks and stateful-widgets are different.

This could lead to subtle difference in behaviour between flutter_riverpod and hooks_riverpod and will require double the effort for any added feature (which is one of the reasons why hooks_riverpod supports useProvider(provider.select) but flutter_riverpod doesn't yet)

On the other hand, ConsumerWidget vs ConsumerHookWidget would not have code duplication

TimWhiting commented 3 years ago

Okay, thanks for explaining. It makes more sense. I'm in favor of the proposal.

Marco87Developer commented 3 years ago

Me too. I’m in favor of the proposal as it was originally written.

Thank you @rrousselGit!

scopendo commented 3 years ago

This could lead to subtle difference in behaviour between flutter_riverpod and hooks_riverpod and will require double the effort for any added feature

That's all the argument I need. Now knowing that, I would happily accept the need to pass a ref through to a custom hook, if I were using HookWidget directly.

And more so going forward I would move away from having hook functions for accessing provider values. Hooks are for adding local state to a StatelessWidget. Riverpod providers are not and so we probably shouldn't be mixing the two.

davidmartos96 commented 3 years ago

@rrousselGit Similar to Tim's idea to try and not maintain two separate implementations, wouldn't it be possible to maintain the hooks implementation and make the ConsumerWidget use the hook implementation under the hood? From the user that doesn't want to use hooks perspective, that is an implementation detail. Maybe you've already thought about this approach, but discarded for some reason. I guess a downside, if it could be seen as such, is that it uses hooks under the hood. I personally don't see it as a problem. I'm interested in hearing your thoughts on this.

This would be the implementation of the ConsumerWidget as I see it:

abstract class ConsumerWidget extends StatefulHookWidget {
  const ConsumerWidget({Key key}) : super(key: key);

  Widget build(BuildContext context, ConsumerReference ref);

  @override
  ConsumerWidgetState createState() => ConsumerWidgetState();
}

class ConsumerWidgetState extends State<ConsumerWidget> {
  @override
  Widget build(BuildContext context) {
    final ref = useProviderRef();
    return widget.build(context, ref);
  }
}
rrousselGit commented 3 years ago

@davidmartos96 that would make everyone using flutter_Riverpod depend on flutter_hooks even if they do not use it.

davidmartos96 commented 3 years ago

@rrousselGit Yes, I know. I'd understand if you don't want to go that way.

dancamdev commented 3 years ago

@rrousselGit I'm in favor of the proposed changes too, your explanation on why useProviderRef() wouldn't work as well makes a lot of sense, thanks for that.

Regarding having riverpod depend on hooks under the hood isn't advisable IMO, the fewer dependencies, the better.

kevlar700 commented 3 years ago

I'm glad of the compile safety and easier switching between hooks and vanilla (syntax parity). Would this mean that existing youtube tutorials for Vanilla would apply to Hooks. Or might users now more readily assume similarity and potentially hit other syntax difference issues?

dancamdev commented 3 years ago

I'm glad of the compile safety and easier switching between hooks and vanilla (syntax parity). Would this mean that existing youtube tutorials for Vanilla would apply to Hooks. Or might users now more readily assume similarity and potentially hit other syntax difference issues?

@kevlar700 the syntax as proposed by Remi should be slightly different if I understood correctly, apart from that people should always refer to the docs for syntax related information, so they're sure it is up to date.

TimWhiting commented 3 years ago

@kevlar700 @dancamdev To clarify, it is different than before - so old tutorials will be outdated, but it should achieve the syntax parity that @kevlar700 is talking about in the future. That is, there should be no differences in syntax and behavior between hooks and vanilla

As Remi said:

what would be the added value of hooks?

That you get to use hooks So you can do:

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

So unless you need to use hooks, there is no difference.

coastalgit commented 3 years ago

I am not too sure about losing the ability to 'read' a provider directly from a StatelessWidget and being forced to break out into a dedicated ConsumerWidget to access the proposed WidgetReference. I do however see the reasoning behind it, in keeping your code clean / "riverpod-like".

I must say I am intrigued by the idea of the ConsumerStateMixin, especially in terms of bringing the magic of Riverpod into existing code.

woelmer commented 3 years ago

I love this proposal. I use hooks and riverpod extensively in my project. I would encourage everyone to look past the migration of your current project and think of the benefits this brings to a new person by unifying the syntax. I can also see the future benefit of not having to explain, to a new developer on my project, the many different ways you can get a provider's value and the way in which useProvider is intertwined with riverpod.

bizz84 commented 3 years ago

I like this proposal a lot! Unified syntax makes it easier to understand and use.

scopendo commented 3 years ago

I've found myself starting to extend a ConsumerHookWidget already! 😄