-
-
Notifications
You must be signed in to change notification settings - Fork 961
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
Sync Providers managing the same entity #3781
Comments
This is a complex problem, and folks have raised issues asking how to deal with his before. I'll keep this open as a reminder to document this. |
Can you elaborate which features Riverpod 3 will offer that makes this simpler? Is there any ETA on Riverpod 3.0? |
I would recommend to adapt a programming pattern like the repository pattern. The repository implements your business logic including caching, pagination, filtering, etc... . Then you can build your state management using riverpod around this repository. |
@snapsl The repository pattern is mostly referred to as an wrapper around multiple data sources and is used to abstract away which data source is being used and how data is being fetched/modified. As far as I understand, a repository is stateless and has "nothing" to do with state except it's the gate to the outside world aka. the data layer. I don't quite see how this is solving the issue mentioned above, happy to hear about how you will solve it. I've read a lot of articles about the usage of riverpod but either they don't run into this issue since they are to simple or they don't don't adhere to the intended way on how to use riverpod. (E.g. Andreas Popular Post about riverpod architecture - I've seen a lot of comments by remi that this and that is not how riverpod is intended to be used). But as said, I am happy about further information or your proposal on how to tackle the above mentioned issue. |
Let's stay with the todo example and create a small sample todo repo to make this more clear. class TodoRepo {
TodoRepo(LocalSource, RemoteSource, ...);
List<Todo> getTodoList()...;
Todo getTodo(String id)...;
Todo updateTodo(String id)...;
...
} This holds all the implementation regarding todos (very simplified). A single source of truth that is encapsulated, mockable, and testable. Cool! Your state management then uses this todo repo. @riverpod
TodoRepo todoRepo(Ref ...) => TodoRepo(....); and use it for our state management. @riverpod
class Todos extends _$Todos {
List<Todo> build() => ref.watch(todoRepoProvider).getTodoList();
void updateTodo(String id) {
final todo = ref.read(todoRepoProvider).updateTodo(id);
state = ...;
ref.invalidate(todoProvider(id));
}
...
}
@riverpod
Todo todo(Ref ref, String id) => ref.watch(todoRepoProvider).getTodo(id); Fixed:
In general there is a reason for these programming patterns to exist since other devs had the same problems and came up with solutions that we should use to not make the same mistakes. I hope this helps you with the real application :) |
Making a repository is only a workaround in Riverpod's case. I won't go into detail here as things are still WIP. But I do want to improve this. |
@rrousselGit creating a riverpod pattern 👍 |
@snapsl I think there are multiple scenarios why this might not work: Reason 1: class TodoRepo {
...
// Fetches the given page of the todos, each containing e.g. 20 todos
List<Todo> getTodoList(int page)...;
...
} @riverpod
class Todos extends _$Todos {
List<Todo> build(int page) => ref.watch(todoRepoProvider).getTodoList(page);
void updateTodo(String id) {
...
}
...
} As mentioned in the original issue, lets still assume we are working on a web application where the user might directly navigate to Reason 2: @freezed
class Todo with _$Todo {
const factory Todo({
required String id,
required String title,
required String description,
/// The date and time the todo is scheduled to be
/// worked on/completed.
/// If this is null, the todo has not been scheduled yet.
DateTime? scheduledAt,
/// The date and time the todo has been completed.
/// If this is null, the todo has not been completed yet.
DateTime? completedAt,
/// The date and time the todo has been completed.
/// If this is null, the todo has not been completed yet.
required DateTime createdAt,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
} As you can see, the Now lets assume we have some widget/page in our app that displays:
For each of these we can make an API call that returns a List:
For each of those, we have a provider of course... I don't think I have to show an example on how to create them. Naturally a todo might end up in multiple lists: Where should I place the update method? In every provider? This is what #3285 was initially about as far as I remember (the issue has been edited) I hope I gave you some understandable and "real-life" why I think your proposal does not solve the issue. |
Okay, I am not sure if it helps to stay with the todo example. But this is still the same principal.
class TodoRepo {
List<Todo> get listTodo = [];
List<Todo> getPage(int page)...;
List<Todo> getRecentTodos();
...
}
Note:
|
See above @riverpod
Todo todo(Ref ref, String id) => ref.watch(todoRepoProvider).getTodo(id); |
@snapsl
In order to keep this thread cleaner, I am also down to discuss further on discord if that's okay with you: My name there is same as in Github |
Hello! 😄 It's been a while since I've tried to tackle this problem, and since then I've recently updated the original issue with a summary of the problem:
I want to share my 2 cents and my experience about this. First, it's best to recognize that this is quite a hard problem. We're exploring boundaries that most client-side applications get wrong, even popular ones. I, too, used a "repository pattern" to solve this in the past months, and I'd definitively advise against such an approach. Assuming you're suggesting this repository should save on a local cache whenever new data is received from our network (e.g. with sqlite):
I want to share another workaround while we wait for R3' docs. Given we have some "shared state" family providers, e.g.: The idea is to save "divergent data" on a separate hash-like data structure (here I'm just saving the In my use case, whenever we hit a "like" button, we write an override in there, meaning: "this has been touched, and the source of truth has now changed". If you're curious I'm working on a repository on this topic since a while, which I've just updated. You can check out my implementation example, there. I can't wait for Remi's solution and for R3! |
@lucavenir Thanks for joining this discussion! I also tried a lot of different solutions that I've tried, e.g. caching and streaming from a repository class ("watchXY -> Stream" and every update emits a the updated value...), listen to the "single item" provider and creating it with already fetched values , by creating a class for the params e.g. "ItemReference" where there is a mandatory id property and an optional value property and overwriting the hashCode and equality operator -> This was you can pass "initial" data to a provider, since an "ItemReference" with only id is equal to an ItemReference with a value. The only way that doesn't feel like actually working AGAINST Riverpod came to my mind after reading your comment, so I still have to try how it feels: Create an "ItemSyncService" - A (family) Notifier which state represents the "cached" value.
typedef ItemSyncServiceState = ({
/// The currently cached value, this is nullable since initially we have no value OR in case the item does not exists
Item? value,
/// Used to determine if some provider set the state or if we haven't fetched it yet.
bool hasValue,
/// Used for optimistic updates. When some provider updates this state optimistically, the pending future should be
// set here so other providers can await that future and revert to their "old state" when the operation fails.
Future? pendingOperation,
}); This way if no one is interested in updates to that item or no one is modifying it, the provider will get disposed. Would appreciate your opinion on it and if you thought about it already too or even tried it. |
I also was thinking about this problem as well.
This way I have N streamproviders If I stream the first 50 and then stream 50 more, I could change the
|
Hi! I would like to second the importance of this problem. I'm relatively new with flutter and have started the first more complex app project using provider. I was thinking about following Andrea's architecture but after reading through this issue here I also don't see how this necessarily solves the problem at hand. It would be amazing if riverpod would make this easier / possible and documentation about this would be unprecedented from what I could find 👀 On the risk of diverging from the specific riverpod solution: One thing that I also found regarding this topic was the offline first approach. But that sounds too good to be true to me? Local db as state manager? If the app use case allows to store the entire (or required selected) data in a local db would that solve the problem of outdated data in different places? |
same. 3.0-dev has been stalled for almost a year. is it still WIP? |
|
FWIW I'm currently investigating a sort of "bundle of provider". As in, a Repository-like with multiple source of states: @someAnnotation
class Users extends _$Users {
@riverpod
Future<User?> byId(String id) => /* http.get(...) */;
@riverpod
Future<List<User>> home() => /* http.get(...) */;
} Where Riverpod would automatically synchronise all |
My main challenge at the moment is figuring out how to enable folks to do a I kind of wish Dart had nested classes. We could then do: @riverpod
class Users {
@riverpod
static Future<User?> byId(String id) => /* http.get(...) */;
@riverpod
class Home {
@override
Future<List<User>> build() => /* http.get */;
Future<void> add(User user) => state = [...state, user];
}
}
...
AsyncValue<List<User>> users = ref.watch(usersProvider.home);
Home home = ref.watch(usersProvider.home.notifier); |
That is great progress! 👍 What about the mutation syntax introduced in #1660? @mutation
Future<void> add(User user) {
return [...state, user];
} A kind of reactive “view” (I couldn't come up with a better name) managed by the same entity would solve this problem. @riverpod
class Users {
@reactive
Future<User?> byId(String id) => /* http.get(...) */;
@reactive
Future<List<User>> home() => /* http.get(...) */;
@mutation
Future<void> addUser(User user) => /* ... */;
}
AsyncValue<List<User>> users = ref.watch(usersProvider.home);
AddUserMutation mut = ref.watch(usersProvider.addMutation); |
Defining a method isn't the issue. I already have working mutations. The problem is about how to do things like |
Each view would have its own state that needs to be updated. E.g. // home view
@reactive
Future<List<User>> home() => /* http.get(...) */;
// recent view
@reactive
Future<List<User>> recent() => /* http.get(...) */;
@mutation
Future<void> addUser(User user) {
home.state = [...user, state]; // but async
recent.state = [...user, state];
// other views don't need to update like 'byId()'
} |
Exposing the state of all providers like that is fairly problematic as it's not reactive. This doesn't work: @riverpod
Future<List<User>> home() async {
return this.recentState;
}
@riverpod
Future<List<User>> recent() => ...; And also, keep in mind that mutations don't return @mutation
int increment() => state + 1; Returning |
For now I'm leaning toward relying on So: @someAnnotation
class Users extends _$Users {
@riverpod
Future<User?> byId(String id) => /* http.get(...) */;
@riverpod
Future<List<User>> home() => /* http.get(...) */;
Future<void> addUser(User user) async {
ref.read(homeProvider._notifier).state = [ref.read(homeProvider._notifier).state, user];
}
} But that's pretty bad syntax wise. And mutations have the added issue that we don't know which state a mutation is associated to. We'd need something like: @Mutation(homeProvider)
Future<List<User>> addUser(User user) async {
return [ref.read(homeProvider._notifier).state, user];
} That's not ideal either. |
Can you elaborate this. @riverpod
class Users {
@reactive
Future<User?> byId(String id) => /* http.get(...) */;
@reactive
Future<List<User>> home() => ref.watch(usersProvider.recent.where((a) => a.isHome == true)); // it's like a computed value
// alternative
@computed
Future<List<User>> home() => this.recent.where((a) => a.isHome == true)); // this is reactive due to @computed
@reactive
Future<List<User>> recent() => /* http.get(...) */;
@mutation
int addUser(User user) => /* ... */;
}
This should not throw in a callback. onPressed: () => addUser(user) If this can be avoided a mutation can also return a mutation state.
I think a mutation should apply to the bundle of user provider and thus to each of its dependent "view" providers. Defining a mutation that only applies to the @mutation
Future<List<User>> addHomeUser(User user) async {
home.state = [...user, home.state]; // but async
...
} |
The problem is that folks can make the mistake and use
I see no relation between this snippet and the fact that mutations return
It can't be separate. The goal of this feature is to allow folks to synchronise independent providers when they manage the same object. |
Another alternative I was considering is instead: const userGroup = Group<User>();
@riverpod
@userGroup
User? byId(Ref ref, String id) => ...;
@riverpod
@userGroup
class Home {
@override
Future<List<User>> build() => ...
} |
I'm referring to #3682 but the wrapper you mentioned should solve this. 👍
Looks promising and declarative! |
No. Don't throw in your mutations if you don't want Riverpod to rethrow errors :) |
I have not seen mutation implemented that way. Commonly you would return a mutation.error state or |
There are two different things here. There is a final increment = ref.watch(provider.increment);
switch (increment.state) {
case IddleMutationState():
case PendingMutationState():
case SuccessMutationState(:final state):
case ErrorMutationState(:final error, :final stackTrace):
} But we're not talking about that here. We're talking about how |
My two cents about the About the Looking forward for this feature to see light. |
+1 The group annotation makes it also easier to split the providers into its own files, while the first approach gives me strong vibes of needing to put all the providers into one file which would be fine too but in case of many "related" providers it can get pretty big. |
Having to put related things together IMO make sense. The main issue is that Dart lacks nested classes. Even if I add that Group thing, the day we have nested classes, I'd add the initial approach. There's a lot of value in being able to do: ref.watch(users.byId(...))
ref.watch(users.home)
ref.watch(users.search('...')) That's way more discoverable than having to guess the provider name. |
Yes, absolutely. This would greatly help maintainability of my codebases atm (: But why private classes? typedef UsersGroup = Never;
extension Users on UsersGroup {
static SomeProvider get home => ...;
}
// and then
ref.watch(Users.home); The above is how the Dart team proposes to namespace The only issue might be |
Maybe like that? const group = Group<User>();
@riverpod
@group
class MyNotifier {...} which generates: extension on Group<User> {
get myNotifierProvider => ...
} Used as: ref.watch(group.myNotifierProvider) That could be useful. I'll consider it |
That's even better! Back to the topic of this issue, how will this syntax help with syncing providers? |
The idea is that by specifying a group, Riverpod would perform some kind of combine_latest for all providers of that group So if you update a user with ID 42, all providers that currently expose such a user receive the same update. |
As far as I understand it will also act as a "group cache". |
I see the following remaining problem with the group syntax.
|
About overriding state changeAbout the "how to react to shared state changes" problem, I would assume there would be an overridable method for each provider subscribing to the I also have this concern, as my use case has "favorites", in which a state change in my home page - i.e. "the item has been favorited" - triggers a completely different change in my favorited list ("add this element on top of this list, and make sure it's favorited since it wasn't in here before"). Is this assumption correct? About docsAbout the documentation concern: I think that's safe to say Riverpod is and will be well documented (: Another concern:
|
No "magic" should happen.
This may be a limitation of the group syntax. In general: |
The goal of Specifically, I was thinking that:
This would work by having Riverpod observe all providers within a group. And whenever a emits an update, it'd look for the ID of the updated item. Then, it'd search within other providers of the same group for other items with the same ID. Lastly, when there's a match, it'd update those providers to use the new value (either with mutation or copy) As such, this is a complete example: class User {
User(this.id);
final String id;
}
const userGroup = Group<User>();
@riverpod
@userGroup
User? byId(Ref ref, String id) => ...;
@riverpod
@userGroup
class Home {
@override
Future<List<User>> build() => ...
} Any update on either Overall, this would probably be a bit magical. We'd also probably have to prevent providers within a group from watching each-other. Because that'd easily end-up in an infinite rebuild loop.
We can have an overridable method on notifiers for that. I'd expect it to look like: @override
void handleGroupUpdate(Iterable<AsyncValue<T>> updates) {
// TODO update your state based on those changes
} It'd probably always use |
Thank you! This is awesome! I would assume this is a lot of work tho. This won't ship with Riverpod 3.0, right? |
We'll see. In terms of difficulty, it's alright. We already have all the pieces necessary to implement this. The main issue here is about "Is that proposal a good idea?" If I release it, I'll likely ship the initial release as "experimental" |
By the way, if someone fancies trying to work on this, I'm willing to get a PR.
For now I'd like to finish offline and a lot of other features. |
Describe what scenario you think is uncovered by the existing examples/articles
It is quite common to have a List provider as well as an Item provider, especially when dealing with
web applications and (deep) links. It is not well documented on how to share state between provider that might
contain the same Item. To be more precise on what I mean, lets take a look at an example:
Lets suppose we develop an Web-Application with the following paths:
and a simple model of the todo as the following:
As you can see, it is a pretty simple setup:
/todos
shows us an overview of the todos (e.g. only the title and the completion status) while/todos/:id/
will display an detailed version of that todo (e.g. title, status, description).Since this is an (web-) application, the user might navigate to the detailed screen without ever rendering the overview screen.
Lets start pretty simple - just have two separate providers that fetch the todos/todo:
Okay - fair! But now the first problem raises:
We might fetch the same entity twice - first when the user visits the overview and second when he visits the detailed view - pretty wasteful on ressources, no? You might argue now that this can be fixed fairly easy by awaiting the todos and returning the todo as a dependent provider.
But now we still fetch the whole list just to get that one provider, so we can even optimize it further...
Okay. Understandable to this point - TLDR; The detailed provider should check the list provider if it exists, and if yes, check its state so we don't fetch an todo twice - seems logical!
But now lets suppose we don't have a single todosProvider, this can be the due to multiple reasons:
Reason 1: The Todos Provider doesn't fetch all the todos, only those that are not completed (=> isCompleted: false). And we have another Todos Provider (uncompletedTodosProvider) that fetches the uncompleted todos. Now we need to modify our todo provider to check 2 possible lists...
In this case is a little trivial but assume we have something else with a lot more properties and a more complex data model, we could end up with 3-4 places to check. While adding all of those providers to check first before fetching the actual item seem a little annoying, I am willing to accept it BUT:
Reason 2 (prob. more common): The Todos Provider is actually a FamilyProvider since we have several thousand of todos (overall we are pretty busy, no?) so that the fetching of all todos would take a long time and a lot of ressources. So we simply add a
int page
to the params of the todosProvider - Lets make a loop to check every familyProvider(page)... but wait - how far should we check? We don't know how many pages there might be...
Let's mentally reset and assume we found a solution OR just go with the simple overfetching - we fetch our todo it in the todosProvider aswell as in the todoProvider 🤷🏼♂️
But aren't Todos supposed to be updatable? I mean we somehow want to check of our todos, right? This means we have to make our todoProvider an class based (notifier) provider.. But updating the state of this provider doesn't update it within the overviewProvider and vice versa.. How can we keep them synchronized?
There just doesn't seem to be an sufficient example that is "complex" enough to showcase how to use riverpod with
this common use-cases above.
I hope with this little example made my problem clear - if not, I am happy to discuss your solutions and will throw further constraints at you! :D
Describe why existing examples/articles do not cover this case
The docs on the riverpod website cover just simple use-cases, either
using read-only (family) provider or local/simple state notifier.
I have been struggling with the problem mentioned for a long time now
and can't find a general solution to tackle this problem. It seems like
other people having problems with this problem (or a related one) too.
E.g. #3285
The text was updated successfully, but these errors were encountered: