felangel / bloc

A predictable state management library that helps implement the BLoC design pattern
https://bloclibrary.dev
MIT License
11.8k stars 3.39k forks source link

Normalized state in Bloc #2280

Closed najibghadri closed 3 years ago

najibghadri commented 3 years ago

Hello, I have a serious question... Like everyone, I am also confused about statemanagement in Flutter, but I really like bloc and this library. But one thing is not clear. I read all the docs and all the tutorials already!

I come from redux where you have one big global store, in which you have normalized states. I don't know if I should or how I should correlate redux to bloc, but they felt similar from the beginning and they still do. You have a state, events and you listen to state change and trigger events..

Say I have an app where I have events, I might create, list an event show it in a screen, in another screen etc. I might fetch and cache an event. Doesn't this require me to have a normalized state somewhere? And where?

Normalized state is basically having a single place for storing an object, like storing it in a map, and referring to that object in other places by id, then using the map to show the object in the UI

So where should I put normalized state in Flutter? In a bloc? In a bloc high up in thr tree? In the repository? Should I use normalized state at all or is there another way to think about this? Thank you!

@felangel

RobertHeim commented 3 years ago

First, you should recognize that a BLoC can have very different purposes depending on the layer it is applied: managing state for a single temporary view up to global state acting as single source of truth. Coming from redux you most likely think of the latter one while the temporary local state is often not managed in the redux store but differently (e.g. using hooks).

Coming from redux, we do it like this for global state with BLoCs

We slice our overall state in features. Each feature has a bloc and acts as single point of truth for that part of our application. A feature is not exactly like a state-slice in redux because it might be responsible for multiple slices of the overall state. The overall state is spread across these top-level BLoCs. We use a MultiBlocProvider to provide all the global blocs at the very root of our app.

Inside a BLoC

Within a bloc we further slice the bloc's state (this is like the state slices in redux).

Most examples around bloc do use different sub types for different state situations. This does not work very well for us in more complex situations. Hence, we most likely just have an immutable class that represents the complete feature state and use copyWith methods to reduce map the state based on a given event.

Regarding normalized state: yes, it is up to the developer to define the shape of the state. So it is possible to ou just implement the bloc state like a redux state. It is not enforced to normalize it, but we do it very similar to redux.

Regarding repositories: We look at it that repositories are more like the API gateway to persistent storage. The BLoC is the in-memory representation of the state and thereby is more like a smart cache. But this is the same in redux where the store is kept in memory and might have stale data in comparison a persistent storage.

Summary

Pain Points

While this works pretty well from point of the architecture, the biggest pain points from our experience is that we cannot easily debug state. In redux we got all the nice time travel stuff and tree views etc. The tools we found for blocs are outdated and require to implement additional stuff into the state classes (like "toMap"). We ended up putting a DEBUG flag in every bloc and implement onTransition by hand to provide debug information if the flag is true. Still cumbersome...

Sometimes we need inter bloc communication. Blocs can listen to each other but this sometimes results in very awkward subscriptions and tracking them. But it kind of is okish.

An other limitation might be that different blocs cannot listen to events of other blocs. From my perception this is fine as it feels like an antipattern (so we gain compile time safety for this unlike redux in JS where all actions are just strings and all reducers may listen to them).

najibghadri commented 3 years ago

@RobertHeim these are exactly the answers I was looking for, very helpful thank you very much!

najibghadri commented 3 years ago

@RobertHeim do you think it's wrong to create multiple Blocs of the same type, like an EventBloc for every event that is loaded into the app or is this a big no? I would use the bloc in every place I want to show the event, this way if the event state changes in one place, it will be consistent in another place, like showing the event in a list and in a detail page.

RobertHeim commented 3 years ago

@najibghadri In general it is fine to create multiple instances of a single bloc. However, if you want consistent states between the instances, I think you are probably better off by creating a single instance and share it. Thereby it becomes your "single point of truth" and all widgets show the same state.

To share a bloc instance you can either

Reusing bloc instances in multiple widgets is one of the main intended use cases.

jerryzhoujw commented 3 years ago

What I have implemented about this, is to put all features repositories in app root with MultiRepositoryProvider, So that,

  1. every feature in each view level will have a bloc it self init with the repository from repositories get from global.
  2. each repository have a global state for each feature.
  3. each repository have a FeatureRestServerDataProvider & FeatureLocalDBServerDataProvider & others if needed.
  4. each repository just handle data merge from each feature's data providers and store some values in memory as properties.
  5. so in my case FeatureRestServerDataProvider is much like the gateway for network request.
  6. so in my case there's no need for global bloc, global state observer can export from repository with a BehaviorSubject<T> on it.

What to you think about these implementation above ?
According to your discuss, I may need to do some change in the future.

felangel commented 3 years ago

Hi @najibghadri 👋 Thanks for opening an issue and thanks to everyone else for weighing in!

I personally recommend having a single bloc per feature rather than having a bloc per data source. This allows you to avoid having many global blocs and also should reduce (or even completely eliminate) the need to have blocs which depend on other blocs.

The approach @jerryzhoujw described is what I'd recommend because it treats the repositories as the source of truth and multiple blocs for different features can depend on the same instance of a repository.

Hope that helps 👍

najibghadri commented 3 years ago

Very enlightening answers, thank you @RobertHeim @jerryzhoujw @felangel! I have some opinions 🧅 and questions on this though:

1.: @jerryzhoujw 's solution would make the repositories api consist of both Streams/BehaviorSubjects and Promises. This will make repositories kind of mixed responsibility: they give promise api for requests, but they also hold state and be smart about whether to use local or remote data, which is actually cool, but we kind of implemented a BLOc there, right? Each Event would be a Stream/BehaviourSubject and there would be a map on this (normalized state). Blocs that need the event's data listen to the right Stream/BS from the map, because in response to each data change the bloc has to emit that change through. But what happens to the Promise requests? Then any request that retrieves an Event should emit the new Event in the right Stream/BS and return the request to the caller? Is this the right approach or:

2.: Why not put this logic into a bloc, and that bloc is the one that is smart about using local or remote data, using the Promise api of a remote/local data repository? Yes this would mean there will be blocs depending on blocs.

So - (what I meant in my previous question) - there would be a global bloc, EventBloc, for each and every Event loaded into the app, and make each event's bloc accessible by the event's id. This means if there are hundreds of event's loaded into the app in a session there will be hundreds of EventBlocs. Each event's bloc would be used throughout the app wherever the event is shown/edited, by other Blocs if needed.

I feel like you recommend against approach 2. @felangel

What are your views on approach 1. and 2.?

jerryzhoujw commented 3 years ago

@najibghadri

they also hold state and be smart about whether to use local or remote data, which is actually cool, but we kind of implemented a BLOc there

In my understand,

  1. Bloc should be as small as possible, so that the logic can be easy to maintain.
  2. Bloc should only handle business logic, like make a change on data.
  3. The data merge thing as I mentioned, is only about the data saving logic, (like saving data in memory, sync to server, saving to db), which I think it's not related to any business change.
  4. so that there can be multi bloc with same type, but no need to sync data between them.

For example, a Editable object can view in a view, and also view in another view, after edit(which is bloc part, business change happened), the data saving(which is not part of bloc part, business change not happen) and fetching logic in the other view (these parts depends on cases)

Blocs that need the event's data listen to the right Stream/BS from the map, because in response to each data change the bloc has to emit that change through

In my implementation, there are few case that need add BehaviorSubjects on repository,

  1. One case I needed is about the current login user state, my app need to check if user login, current permission, isVIP, isVIP expired, level and so on, so I need accessible way to check the if the user is VIP, so i just access the BehaviorSubjects's value, and done.
  2. Another case I needed is an unread app's system message count, which is show global, so I better put a BehaviorSubjects on that.
  3. In most of other case, I didn't put much BehaviorSubjects in repository.
RobertHeim commented 3 years ago

Regarding repositories: I consider them being responsible for persistence and providing an API facade for it. They can use (single or multiple) local or remote storages ("data providers") and provide a promise-based API. BLoCs are more like a service layer that implements the business logic between UI (Widgets) and data (Repositories). However, the BLoCs need to track request results (e.g. if a save is successul) but they do not care about how exactly saving takes place. This is up to the repositories. I typically just await the repository call in cases where the bloc is not required to process multiple events in parallel and use async-await-add for scenarios where async tracking of the requests is required.

Besides persistence I think it is a matter of taste and over all architecture on which level the blocs operate and how we split or architecture the complex blocs to keep testability, separation of concerns etc in shape. Regarding state management I think that in 2021 we know that predictable state management is hard and a good solution is the flux/redux pattern. I would decide that on a per feature-base. I think that complex state management scenarios (more than a simple weather or todo app) require advanced solutions. State management is hard and the bloc pattern does not define how you manage the state but "only" separates and links UI and the logic/service/feature. It still needs to manage its state. If this state is considered local state or global state depends on how we use the bloc. We might end up with a complex widget that has a bloc attached to handle the logic but only manages the local state of that single instance of the widget and we may have globally available blocs acting as singleton and implementing a complete feature of the system.

najibghadri commented 3 years ago

Thank you very much everyone 🙇‍♂️, I think I got it. In the end I agree that it's always specific to the problem but it's good to hear about experienced people's opinions. For my part this thread can be closed.

cedvdb commented 2 years ago

If the bloc is used for UI state, and repositories which are part of the data layer have the data in memory, then the business layer is probably splits between those two layers or alternatively non existent, which is odd considering the library you are using is named Bloc (Business logic component) and used simply for UI layer state.