riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

Add a new provider called the "MaybeProvider"

Open temcewen opened this issue 1 year ago • 8 comments

Is your feature request related to a problem? Please describe. I also describe the problem here: https://github.com/rrousselGit/riverpod/discussions/2388

To summarize: the issue is that data often has multiple sources that shouldn't all be evaluated, may not be known until runtime, and could be the direct result of a widget's build method. Providers are currently limited because they have the relative restriction of always getting data from sources that are known, already evaluated, or they cannot be set during a widget's build method (for good reasons).

The example I gave in the linked discussion forum is the easiest for me to think about. Imagine you have a post model for a social media. This model can come from the API directly via its GUID, but it can also come from a request to a paginator for the feed or from a request to the owning user's profile.

Modeling the autodisposable family provider for this post model could look like this:

@riverpod
Future<PostModel> fetchPost(string id) {
   // create api request for the model via the id
   return await dataSource.getPost(id);
}

Notice how the provider specifically doesn't use any references to providers for the feed or the profile? It's because doing so is practically useless! Consider that if you were to watch the autodisposable family providers for the post and feed, you wouldn't be able to do so for any specific post model because you first need to have context for them. For the feed paginator provider, you would need to know exactly what page the post model is on which is impossible until runtime. For the profile provider, you would need to know what user the post belongs to which is also impossible until runtime.

You might be thinking, just create providers for the feedPostFamilyProvider and profilePostFamilyProviders which evaluate to null until the values are set at which point the postFamilyProvider could use them to get the most recent version of the post model.

This would look like:

@riverpod
Future<PostModel> fetchPost(string id) {
   // all are nullable values
   var feedPostModel = ref.watch(feedPostProvider(id));
   var profilePostModel = ref.watch(profilePostProvider(id));

   // only fetch the post from the api if it doesn't exist from the feed or profile providers
   var apiPostModel = (feedPostModel  ?? profilePostModel) == null ? await dataSource.getPost(id) : null;

   // get the most recent version of post model and ignore nullable values
   var recentVersion = _mostRecent([feedPostModel, profilePostModel, postAPIProvider]);

   return recentVersion;
}

The issue is that in order for feedPostModel and profilePostModel to work, they need to be able to evaluate to null and later on be set to an actual value which will cause postProvider to reevaluate and choose the most recent model.

This means that they should probably be autodisposable family Notifier<PostModel> with build methods that create null values. When they are ready to be set, a method will be called on them which will cause their values to be set. This is a problematic solution because these values won't always be set by a specific user action, but often they should often be set through the build methods of widgets. For example, the feed must be evaluated as soon as the app is opened. This means that it should always be evaluated while a widget is being built. The user's own profile will also need to be evaluated when the app is built. This means that the notifier's methods should be called while widgets are building which is a bad practice according to Riverpod's best practices: https://github.com/rrousselGit/riverpod/issues/1762

Describe the solution you'd like I think the creation of something called the "MaybeProvider" would not only simplify the way this problem can be solved, but it would also allow for a relatively clean solution that still adheres to a reactive way of thinking.

The essential idea of a MaybeProvider is that it decouples the observation of a provider from its evaluation.

The MaybeProvider would use these simple principles:

  1. ref.watch never calls it's build method, but always forces an immediate unwrapping of its value which would evaluate to null
  2. ref.build must be called on a MaybeProvider for its build function to run
  3. ref.build is only initially evaluated once just like ref.watch, and it only reevaluates if one of its dependencies changes (through ref.watch)

The advantage here is that you can defer the evaluation of a Provider while still observing its value. Take the previous example but assume that feedPostProvider and profilePostProvider are autodisposable family MaybeProviders:

@riverpod
Future<PostModel> fetchPost(string id) {
   // all are nullable values
   var feedPostModel = ref.watch(feedPostProvider(id));
   var profilePostModel = ref.watch(profilePostProvider(id));
   
   // only fetch the post from the api if it doesn't exist from the feed or profile providers
   var apiPostModel = (feedPostModel  ?? profilePostModel) == null ? await dataSource.getPost(id) : null;

   // get the most recent version of post model and ignore nullable values
   var recentVersion = _mostRecent([feedPostModel, profilePostModel, postAPIProvider]);

   return recentVersion;
}

Now, if the particular family values for feedPostProvider(id) and profilePostProvider(id) haven't had ref.build called on them, they will immediately evaluate to null. However, if ref.build is called on them in the future, the observation through ref.watch will still trigger a rebuild of postProvider.

ref.build should be able to be called from a widget's build method. While this would cause its dependent providers to reevaluate, it won't cause them to do so during the widget's build. Why? Because ref.build of a MaybeProvider must always be asynchronous. To further clarify, this is what the implementation of a MaybeProvider could look like for a post model that is retrieved from the profile:

@Riverpod(maybe: true)
class FetchProfile extends _$FetchProfile {
   // this results in the generator creating the family version of this provider
   ProfileModel? get(string profileId);

   // arguments after ref are NOT the family input
   @override
   Future<ProfileModel> build(FetchProfilePostRef ref, string profileId) async {
      var profile = await dataSource.getProfile(profileId);
      
      for (var post in profile.posts) {
         // second argument after the provider is the build argument
         await ref.build(fetchProfilePostProvider(post.id), {profile.id, post.id});
      }
      return ref.watch(fetchProfileProvider(arg.profile))[arg.postId];
   }
}

@Riverpod(maybe: true)
class FetchProfilePost extends _$FetchProfilePost {

   // this results in the generator creating the family version of this provider
   PostModel? get(string postId);

   // arguments after ref are NOT the family input
   @override
   Future<PostModel> build(FetchProfilePostRef ref, {required string profileId, required string postId}) async {
      return ref.watch(fetchProfileProvider(profileId))[postId];
   }
}

@riverpod
Future<PostModel> fetchPost(string id) {
   // all are nullable values
   var feedPostModel = ref.watch(fetchFeedPostProvider(id));
   var profilePostModel = ref.watch(fetchProfilePostProvider(id));
   
   // only fetch the post from the api if it doesn't exist from the feed or profile providers
   var apiPostModel = (feedPostModel  ?? profilePostModel) == null ? await dataSource.getPost(id) : null;

   // get the most recent version of post model and ignore nullable values
   var recentVersion = _mostRecent([feedPostModel, profilePostModel, postAPIProvider]);

   return recentVersion;
}

I omitted the fetchFeedPostProvider for brevity. It's important to reiterate that the build argument which is provided is NOT the same as the family id. This allows MaybeProviders which aren't families to also have arguments which are provided and it gives the ability for complex connections between providers which won't be known until runtime.

It's also important to note that ref.build will only be evaluated ONCE and not every time it's called. It's just like all other build methods in that the initial evaluation only happens when it's first called, and in order for it to be reevaluated, it must be due to a change in one of its dependencies. This prevents any sort of circular dependency from happening when build is called on a provider that's dependent on the provider which called build like in the given example of fetchProfileProvider and fetchProfilePostProvider.

TLDR the idea is simply to have a provider which decouples observation from evaluation. My proposal doesn't have to be the way that it's done, but I think it needs to be done somehow because otherwise enterprise level apps with data which comes from multiple sources are going to continue to use wacky hacks or just abandon Riverpod altogether.

Describe alternatives you've considered I gave most of the alternatives in my description of the problem. There is a "hack" that sort of works for now by using a FutureProvider with an asynchronous build method and calling the notifiers of other providers in the build method after it awaits some particular data. While this does work, it's against the best practices. I think there needs to be a recommended solution for this problem.

One user, @mattermoran, said that they use Hive as a cache and the ultimate source of truth for models which solves this problem. While this may work, it's weird to me that there isn't a native Riverpod solution. I don't like the idea of needing to "hack" Riverpod to get this done. It means loads and loads of extra hours of work for building enterprise level solutions which will almost always have data coming from multiple sources.

Additional context Any feedback on this idea is appreciated. I'm not the best Flutter or Riverpod programmer out there, so I understand if there are issues with what I'm proposing. I imagine that the biggest problem with this solution is the difficulty in its implementation just because it's so much different than any of the other providers.

temcewen avatar Mar 28 '23 17:03 temcewen