mrpmorris / Fluxor

Fluxor is a zero boilerplate Flux/Redux library for Microsoft .NET and Blazor.
MIT License
1.25k stars 144 forks source link

Discussion: Action-reducer-effect-action-reducer vs. asynchronous action-handler #225

Closed szalapski closed 2 years ago

szalapski commented 2 years ago

After implementing the same state management using both Fluxor and Blazor-State, it strikes me that the most significant difference is that Blazor-State combines the notions of Reducers and Effects into one thing, called "Handlers". In Blazor-State, a handler is by default asynchronous, so that you can dispatch (called "Send" in Blazor-State) an action, then its handler can modify the state, then call out to a web service or other "outside" thing asynchronous, then modify the state again. So, for a routine action that gets data from a service, in Blazor-State, I need only one action and one handler. The caller can do things while the handler starts running, then await the single dispatch call when we want to react to it. On the await, the component can (will) rerender with the newly changed state (e.g. to show a Loading spinner based on the state) and then after the await completes, the component can (will) rerender with the newly changed state (e.g. to remove the spinner and show the new data from the state).

Of course, Fluxor has separate Effects and Reducers, so to do the same thing in Fluxor, I need two separate actions and thus two reducers, and an effect: One parameterless action that results in a reducer and invokes the Effect, then the Effect calls an outside service in a background task that my component can't see, then the effect dispatches a second action, whose reducer can change the state with the newly received data.

My naive look at things makes me appreciate the simpler model of combined effects and reducers--I don't see what the added complexity of separating them gives me as a benefit.

Have you thought through the concept of enabling asynchronous effects or reducers? Did you avoid them in Fluxor with a design purpose in mind? Or was it just the first way to do it? Would the ability to combine Reducers and Effects into asynchronously-dispatched action handlers be welcome as an alternative way to do things?

The idea of introducing the simpler way to my team is quite appealing, though it seems in other ways Fluxor is the better library. But I'm guessing I'm missing something. Any insight you have time to share would be appreciated.

(Note: in Blazor-State the state is mutable by handlers, rather than purely immutable. I think this is rather irrelevant to my question, as it isn't how to get to a new state that matters here.)

mrpmorris commented 2 years ago

Hi

I didn't know that about Blazor State worked in this way.

I'm not sure how Steve (a very good friend of mine) achieves what he did, but I suspect it is because

  1. The state object you are passed is the same instance as the one currently being rendered in the UI
  2. That state is mutable
  3. When you first await in a method invoked by Blazor, Blazor will immediately re-render
  4. When your async method completes, Blazor will render again

We can confirm my hypothesis like so

{
  // 1 - Change some of the state
 await Task.Delay(1000); // This should re-render the Blazor UI
 // 2 - Change the state again
 await Task.Delay(1000); // This will not re-render the Blazor UI
 // 3 - Change the state again
 // The async method is complete, so Blazor will render again
}

If this behaves as I suspect it will, then I think you have actually found a bug in Blazor State rather than a feature.

The reducer should return the new state, and then the store notifies all interested parties that it has changed. If the state can be modified (rather than replaced) throughout the method then you are depending on a quirk of the UI (render on first await) to detect the changes rather than the library notifying subscribers there has been a change to state.

As the changes are not being notified to subscribers, then you have to have a call to StateHasChanged before the await in Step 2 in order to see the middle changes, but then you are putting UI logic into your reducer and also tying your logic to a single UI framework. Essentially, your state is getting concerned with rendering details, and that's not a good mix.

I wrote Fluxor according to how I understood the Flux pattern, which is

  1. The only way to alter state is to dispatch an action
  2. Only one action can be dispatched at a time
  3. Reducers should be pure functions
  4. Reducers should take the existing state + action and return new state

So, your dispatch should be synchronous and replace state immediately. Any async work needs to be done in the background (after the action has been reduced and the subscribers notified). The effect that does the background task cannot update the state directly, but must dispatch a new action to do so.

I don't know exactly what problem you are trying to solve by updating the state multiple times, so I will imagine a scenario that might need it.

  [EffectMethod]
  public async Task Handle(IDispatcher dispatcher, GetClientAction action)
  {
   var financial = await ...get data from finances service...;
   var orderHistory = await ...get data from orders service...;
   var shippingHistory = await ...get data from shipping service...;

   var newChunkOfState = new ChunkOfState(financial, orderHistory, shippingHistory);
   dispatcher.Dispatch(new GetClientActionResult(newChunkOfState));
  }

But instead of displaying the end result in one go, you want to update the UI as it comes in

  [EffectMethod]
  public async Task GetFinancialData(IDispatcher dispatcher, GetClientAction action)
  {
    var result = await ............;
    dispatcher.Dispatch(new GetClientActionResult(financialData: result.Value);
  }

  [EffectMethod]
  public async Task GetOrderHistoryData(IDispatcher dispatcher, GetClientAction action)
  {
    var result = await ............;
    dispatcher.Dispatch(new GetClientActionResult(orderHistoryData: result.Value);
  }

  [EffectMethod]
  public async Task GetShippingHistoryData(IDispatcher dispatcher, GetClientAction action)
  {
    var result = await ............;
    dispatcher.Dispatch(new GetClientActionResult(shippingHistoryData: result.Value);
  }

The reducer for GetClientAction would null out those three chunks of data from your state. Your GetClientActionResult would only update the state with properties of GetClientActionResult that are not null - so you get an incremental update in the UI.

Your IsLoading state property would be calculated as

public bool IsLoading => FinancialData is null || OrderHistoryData is null || ShippingHistoryData is null;

Without knowing what you are doing it is difficult to advise. I hope this helps?

szalapski commented 2 years ago
...
 // 2 - Change the state again
 await Task.Delay(1000); // This will not re-render the Blazor UI

If this behaves as I suspect it will, then I think you have actually found a bug in Blazor State rather than a feature.

Yes, confirmed this. You are exactly right: it will rerender on the first await and then again when the method is done, as well as on any other awaits after an intervening call to StateHasChanged--but not on each await generally.

I don't know exactly what problem you are trying to solve by updating the state multiple times...

Your example is pretty close, though mine is simpler: change the state to show a loading spinner before data is retrieved, retrieve data, and change to hide the loading spinner and show the new data.

Yes, I agree that the reducers and effects should not be concerned with refreshing the UI directly.

I will ponder this a bit.

szalapski commented 2 years ago

By the way, doing...

Your IsLoading state property would be calculated as

public bool IsLoading => FinancialData is null || OrderHistoryData is null || ShippingHistoryData is null

Is often naive--the spinner might show before loading is even attempted, such as when we are waiting for a condition (or even initial rendering) or perhaps when loading encounters an error. I think loading is almost always part of state that must be managed, otherwise the edge cases bite you.

mrpmorris commented 2 years ago
[ReducerMethod(typeof(GetCustomerAction))]
public static MyState ReduceGetCustomerAction(MyState state)
  => state with { IsLoading = true };

[ReducerMethod]
public static MyState Reduce(MyState state, GetCustomerActionResult action)
  => state with { IsLoading = false, CustomerInfo = action.CustomerInfo };

Then you'd have an effect method that reacts to GetCustomerAction, calls the server, then dispatches data from the server inside a GetCustomerActionResult.

hellfirehd commented 2 years ago

Then you'd have an effect method that reacts to GetCustomerAction, calls the server, then dispatches data from the server inside a GetCustomerActionResult.

This is the way.

mrpmorris commented 2 years ago

You'd have to have a non-null empty state, but a counter will work too.

mrpmorris commented 2 years ago

Closing this. Let me know if you want it reopened.

szalapski commented 2 years ago

I am looking for a simpler alternative to introduce to those brand new to state management, maybe as a "gateway" to the full model as you describe. As it is, for the most common case of updating the state both before and after an async API call, I need 2 actions, 2 reducers, and an effect. Is there anything that could be consolidated here? Maybe if so, it could be explained in the documentation that such a "handler" really is just sugar for a combined effect and reducer, and calling them async can result in, at best, one rerender before and one rerender after? It also has the nice effect of not needing a State event or variable to tell us when the action is done, as awaiting the handler takes care of that.

Make the simple things simple and the complicated things possible. You've done the latter; could we consider some way to enable the simple case?

mrpmorris commented 2 years ago

I'm not aware of an approach that does what you want without breaking the rules of the Flux pattern, and I can't think of anything.

If you can find something in Redux or something then I'll happily look.

szalapski commented 2 years ago

I still think that idea of providing async "handlers" an alternative to separate effects and reducers has some merit. Yes, they would be useful only for up to two state changes sandwiching one or more awaits, but this strikes me as a very common scenario.

Another way to think of it is that Fluxor has effects that run in the background, but Blazor-State has handlers that run via async-await, giving more exposure of what's happening to components.

(As an aside, I wonder if there would be a way to leverage IAsyncEnumerable to allow components to do something like await foreach (_ in multiStepTask) StateHasChanged();. Just a thought...)

mrpmorris commented 2 years ago

Blazor State isn't the Flux pattern so is free to work differently.

I don't see the benefit of being able to update the state at the beginning and end of an async method but not in the middle. You can do that with an Effect though.

[EffectMethod(typeof(SomeAction))]
public async Task HandleSomeAction(IDispatcher dispatcher)
{
  await Something1();
  dispatcher.Dispatch(SomeActionUpdate { Name = "Bob" });
  await Something2();
  dispatcher.Dispatch(SomeActionUpdate { Age = 42 });
  await Something3();
  dispatcher.Dispatch(SomeActionUpdate { Salutation = "Mr" });
}
szalapski commented 2 years ago

Well, isn't one of the most common needs of any single-page-app is change the state, do something that takes noticable time, and then change the state again? Don't the majority of API calls that any site uses follow this pattern?

mrpmorris commented 2 years ago

That's a very common requirement, yes. Flux's pattern is to do that in effects, and to keep reducers synchronous in order to keep state updates predictable, thread safe, and to ensure UI is updated for the user every time you want it to, not just once at the start and once at the end.

Imagine your request streams a large file to a server. You don't just want to show your user 0% then a minute later 100%.

You want your code to notify updates frequently. To do this in Fluxor you'd dispatch progress actions you update the state and the UI would respond each time.

In an async reducer you'd either have to call the UI to tell it to update, or allow the reducer to dispatch further events before it has itself finished. This introduces race conditions on the state and you can end up with inconsistent state.

szalapski commented 2 years ago

"Imagine your request streams a large file to a server. You don't just want to show your user 0% then a minute later 100%."

Sure, but that is the rarer case, and for that, I agree the standard flux-compliant approach should remain--and even remain as the "main" way you show in the README and any other documentation.

But I am just advocating for the common case, when the server interactions take a few seconds or less, to have a simpler alternative. You have made the complex things possible, but you haven't yet made the simple things simple.

mrpmorris commented 2 years ago

It is possible, you can have an effect dispatch multiple actions rather than have a reducer update state multiple times in an impure function.

szalapski commented 2 years ago

I agree it is possible, however, it isn't simple. I think I will avoid this for now, as I am concerned that the complexity of introducing state management to my team as a concept along with the complexity of having to code the state plus five other parts to make one property change is too much complexity for them. But thanks for the dialog about it.

mrpmorris commented 2 years ago

Is your app something that shows many different views of the same data, or has many different ways of editing the same entity?

If not, then Flux probably isn't the approach next suited to your needs.

szalapski commented 2 years ago

Not many views of many properties, but sometimes two views of some properties.

lonix1 commented 1 year ago

@szalapski Very interesting discussion, and agree that there are many moving parts to do simple things. (But also realise that it's generally necessary.) What did your team eventually do - did you team adopt the fluxor / "blazor-state" / some other way of doing things?

szalapski commented 1 year ago

I think it is important to note that state management in general is not necessarily a best practice, just one option on how to do things. We've introduced a global "AppState" cascading parameter, but not much goes into it. It isn't the Redux pattern. We try to rely on injectible things with "Scoped" lifetime instead.

lonix1 commented 1 year ago

Thanks. So basically the way promoted on the MS docs site - sharing state via services and parameters.

That's how I was doing it until I found fluxor. Now I'm considering different approaches. Flux is amazing stuff, but for a small project there's a lot of boilerplate.

(No offence mrpmorris, I think this library is excellent! It's just important to know when to use it, and that's something I'm not yet able to do - I'm still a newbie!)

mrpmorris commented 1 year ago

No offence taken, I mostly don't use this pattern myself.

I would consider it for an app in which there are many ways to view/modify the same data (e.g. gmail) but not for apps that have lots of entities that you perform CRUD operations on in isolation.

lonix1 commented 1 year ago

Thanks for that insight - suddenly "it just clicked" for me. :)

I'm going to slog through it anyway (despite the unnecessary burden on a small project) as I think it's a useful skill to have. Enjoying it so far, thank you. And blazor-university is an incredibly useful site, thanks for that too (the MS docs site is great, but gosh is it verbose!)

(PS: if you don't typically use it, do you also use the services/cascades promoted by MS?)

mrpmorris commented 1 year ago

Can you give me a link to some example docs?

lonix1 commented 1 year ago

I'm unsure whether I'm using the right terminology, I meant the "out-of-the-box" state stuff on the MS docs site, and as one typically finds on StackOverflow.

mrpmorris commented 1 year ago

For simple stuff that I just need to persist across pages (e.g. search results) I just create a class and register it as scoped. Then I inject that into my component and update it.