felangel / bloc

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

[Proposal] Replace `mapEventToState` with `on<Event>` in `Bloc` #2526

Closed felangel closed 3 years ago

felangel commented 3 years ago

Hello everyone! ๐Ÿ‘‹

First of all, I want to thank everyone for the amazing support and community that has grown around the bloc library! ๐Ÿ™๐Ÿ’™

Context

This proposal aims to address 3 problems with the current mapEventToState implementation:

  1. Predictability
  2. Learning Curve and Complexity
  3. Boilerplate

Predictability

Due to an issue in Dart, it is not always intuitive what the value of state will be when dealing with nested async generators which emit multiple states. Even though there are ways to work around this issue, one of the core principles/goals of the bloc library is to be predictable. Therefore, the primary motivation of this proposal is to make the library as safe as possible to use and eliminate any uncertainty when it comes to the order and value of state changes.

Learning Curve and Complexity

Writing blocs requires an understanding of Streams and async generators. This means developers must understand how to use the async*, yield, and yield* keywords. While these concepts are covered in the documentation, they are still fairly complex and difficult for newcomers to grasp.

Boilerplate

When writing a bloc, developers must override mapEventToState and then handle the incoming event(s). Often times this looks something like:

@override
Stream<State> mapEventToState(Event event) async* {
  if (event is EventA) {
    yield* _mapEventAToState(event);
  } else if (event is EventB) {
    yield* _mapEventBToState(event);
  }
}

Stream<State> _mapEventAToState(EventA event) async* {...}
Stream<State> _mapEventBToState(EventB event) async* {...}

The important logic usually lives inside _mapEventAToState and _mapEventBToState and mapEventToState ends up mainly being setup code to handle determining which mapper to call based on the event type. It would be nice if this could be streamlined.

Proposal ๐Ÿฅ

I am proposing to remove the mapEventToState API in favor of on<Event>. This would allow developers to register event handlers by calling on<Event> where Event is the type of event being handled. on<Event> would provide a callback (Event event, Emitter<State>) {...} which would be invoked when an event of type Event is added to the bloc. Developers could then emit one or more states in response to the incoming event.

For example, if we look at the CounterBloc for reference, the current implementation might look something like:

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0);

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    if (event is Increment) {
      yield state + 1;
    }
  }
}

With the proposed changes the CounterBloc would look something like:

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
  }
}

If we wanted to support multiple events:

abstract class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<Decrement>((event, emit) => emit(state - 1));
  }
}

For more complex logic it can be refactored to look like:

abstract class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>(_onIncrement);
    on<Decrement>(_onDecrement);
  }

  void _onIncrement(Increment event, Emitter<int> emit) {
    emit(state + 1);
  }

  void _onDecrement(Decrement event, Emitter<int> emit) {
    emit(state - 1);
  }
}

These changes address the predictability issues mentioned above because it can be guaranteed that the bloc's state will update immediately when emit is called ensuring that cases like this behave as expected:

  void _onIncrement(Increment event, Emitter<int> emit) {
    emit(state + 1); // state starts off at 0 so we emit 1
    emit(state + 1); // state is 1 now so we emit 2
  }

In addition, developers don't have to use async generators (async*, yield, yield*) which can introduce complexity and undesired behavior in advanced cases.

This allows developers to focus on the logic by directly registering an event handler for each type of event which streamlines the bloc code a bit further.

An added benefit is the added consistency across Cubit and Bloc -- both trigger state changes via emit and the transition from Cubit to Bloc should become simpler.

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
}

Becomes

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>(_onIncrement);
  }

  void _onIncrement(Increment event, Emitter<int> emit) {
    emit(state + 1);
  }
}

Or as mentioned above (for simple cases)

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
  }
}

These changes will obviously be breaking changes that impact blocs (cubits will remain unaffected) so they would all be within the scope of a v8.0.0 release.

The changes would be made in a way that only impacts the bloc mapEventToState implementation. The way blocs are used and tested will be 100% backward compatible which means the changes will be scoped to just the mapEventToState code and can ideally be automated via a code mod. There should be no impact to the remaining ecosystem (flutter_bloc, bloc_test, hydrated_bloc, replay_bloc, etc...).

Please give this issue a ๐Ÿ‘ if you support the proposal or a ๐Ÿ‘Ž if you're against it. If you disagree with the proposal I would really appreciate it if you could comment with your reasoning.

Thanks so much for all of the continued support and looking forward to hearing everyone's thoughts on the proposal! ๐Ÿ™


08/31 UPDATE

Hey everyone, just wanted to give a quick update:

We currently have the v8.0.0 branch which replaces mapEventToState with on<Event>; however, we were able to make on<Event> backward compatible with mapEventToState ๐ŸŽ‰ . You can view the changes as part of the v7.2.0 branch.

The current plan is to roll out bloc v7.2.0 in the coming days which will deprecate mapEventToState, transformEvents, and transformTransitions and will introduce the new on<Event> API. We will have a comprehensive migration guide explaining all of the changes and how to migrate over. During this time, we encourage everyone to upgrade to bloc v7.2.0 and start to migrate blocs one at a time. In the meantime, we'll be working on development releases of bloc v8.0.0.

As part of v8.0.0 all deprecated APIs from v7.2.0 will be removed and the tentative plan is to publish a stable v8.0.0 release about a month after v7.2.0 has been release. This should give everyone some time to incrementally migrate and for any adjustments to be made. In addition, v7.x.x will still receive bug fixes for the foreseeable future so there should be no pressure/urgency to jump to v8.0.0.

Let us know if you have any questions/concerns.

Thanks for everyone's feedback, patience, and continued support! ๐Ÿ’™ ๐Ÿ™

09/02 UPDATE

We just published bloc v7.2.0-dev.1 which introduces the on<Event> API and is backwards compatible which should allow you to migrate incrementally. ๐ŸŽ‰ โœจ

bloc-v7 2 0

Release Notes: https://github.com/felangel/bloc/releases/tag/bloc-v7.2.0-dev.1

Please try it out and let us know if you have any feedback/questions, thanks! ๐Ÿ™ ๐Ÿ’™

09/09 UPDATE

We just published bloc v7.2.0-dev.2 ๐ŸŽ‰ Release Notes: https://github.com/felangel/bloc/releases/tag/bloc-v7.2.0-dev.2

09/10 UPDATE

We just published bloc v7.2.0-dev.3 ๐ŸŽ‰ Release Notes: https://github.com/felangel/bloc/releases/tag/bloc-v7.2.0-dev.3

09/21 UPDATE

The time has come ๐Ÿฅ ๐Ÿฅ ๐Ÿฅ

bloc v7.2.0 is now out ๐ŸŽ‰

๐Ÿ“ฆ update now: https://pub.dev/packages/bloc/versions/7.2.0 ๐Ÿ“” migration guide: https://bloclibrary.dev/#/migration?id=v720

rishabhdeepsingh commented 3 years ago

I Like this one <3

Gene-Dana commented 3 years ago

With the understanding that this will not impact everything that's already in place, meaning this change will not require a refactor in the presentation or the domain layer, this is epic.

To me, simplicity is elegance and this makes a lot of sense !

Instead of

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    if (event is Increment) yield* _mapIncrementToState(event);
  }

  Stream<int> _mapIncrementToState(Increment event) async* {
    yield state + 1;
  }

we have

   on<Increment>((event, emit) => emit(state + 1));

And to me that is infinitely more elegant

jolexxa commented 3 years ago

This means developers must understand how to use the async, yield, and yield keywords. While these concepts are covered in the documentation, they are still fairly complex and difficult for newcomers to grasp.

That was definitely the biggest pain point while learning Dart, Flutter, and Bloc at the same time. I would often get confused as to when to use async vs async* and yield vs yield*.

In addition, developers don't have to use async generators (async, yield, yield) which can introduce complexity and undesired behavior in advanced cases.

One of the cool things about bloc is how native it felt by encouraging the use of Dart's built in generator features (despite the learning curve). I still think this is a good change, though, given how much it reduces the amount of boilerplate needed and the fact that Dart still hasn't released the fix for such a critical bug.

This allows developers to focus on the logic by directly registering an event handler for each type of event which streamlines the bloc code a bit further.

That is actually the biggest benefit of adopting this approach, to me. It feels much more declarative, cleaner, and seems to fit nicely within bloc's approach to state management.

With the proposed changes the CounterBloc would look something like:

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
   on<Increment>((event, emit) => emit(state + 1));
  }
}

That cleaned up nicely ๐Ÿ˜ฎ

AbhishekDoshi26 commented 3 years ago

This looks clean and awesome. Just a doubt, will this be creating a subscription or listener kinda thing when the object of bloc is created? Eg: CounterBloc _counterBloc = BlocProvider.of(context); //Will this create a listener or subscription to:

 CounterBloc() : super(0) {
    on<Increment>(_onIncrement);
    on<Decrement>(_onDecrement);
  }

as there won't be any method now.

felangel commented 3 years ago

This looks clean and awesome. Just a doubt, will this be creating a subscription or listener kinda thing when the object of bloc is created? Eg: CounterBloc _counterBloc = BlocProvider.of(context); //Will this create a listener or subscription to:

 CounterBloc() : super(0) {
    on<Increment>(_onIncrement);
    on<Decrement>(_onDecrement);
  }

as there won't be any method now.

Thanks for the feedback! It shouldn't create any additional subscriptions -- it'll just register the handler for that event type and under the hood the number of subscriptions should stay the same. Hope that helps ๐Ÿ‘

prakash-O4 commented 3 years ago

That was pretty helpful and easy to understand

mrverdant13 commented 3 years ago

Really nice and semantic refactoring! ๐Ÿš€ (Feels like C/C++ enum-based-event handling)

Any way to unregister or update an event handler? ๐Ÿค” It would add more flexibility for cases when a cubit/bloc behavior might change dynamically in the run.

cmc5788 commented 3 years ago

What would an example that includes some basic async processing look like?

felangel commented 3 years ago

Really nice and semantic refactoring! ๐Ÿš€ (Feels like C/C++ enum-based-event handling)

Any way to unregister or update an event handler? ๐Ÿค” It would add more flexibility for cases when a cubit/bloc behavior might change dynamically in the run.

Thanks! I thought about having on returning something similar to a subscription so you could potentially dynamically register and unregister handlers but unless there's a compelling use case I'd prefer to keep it simple.

felangel commented 3 years ago

What would an example that includes some basic async processing look like?

It could look something like:

abstract class WeatherEvent {}
class WeatherRequested extends WeatherEvent {
  WeatherRequested(this.latLng);
  final LatLng latLng;
}

abstract class WeatherState {}
class WeatherInitial extends WeatherState {}
class WeatherLoadInProgress extends WeatherState {}
class WeatherLoadFailure extends WeatherState {}
class WeatherLoadSuccess extends WeatherState {
  WeatherLoadSuccess(this.weather);
  final Weather weather;
}

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  WeatherBloc(this.weatherRepository) : super(WeatherInitial()) {
    on<WeatherRequested>(_onWeatherRequested);
  }

  final WeatherRepository weatherRepository;

  void _onWeatherRequested(WeatherRequested event, Emit<WeatherState> emit) async {
    emit(WeatherLoadInProgress());
    try {
      final weather = await weatherRepository.getWeather(event.latLng);
      emit(WeatherLoadSuccess(weather);
    } catch (_) {
      emit(WeatherLoadFailure());
    }
  }
}

Hope that helps ๐Ÿ‘

cmc5788 commented 3 years ago

Gotcha, so the idea is that on<T> registrars would accept async or async* functions still to manage arbitrarily complex event handlers?

felangel commented 3 years ago

Gotcha, so the idea is that on<T> registrars would accept async or async* functions still to manage arbitrarily complex event handlers?

Yeah on<T> would allow you to register an event handler for events of type T which could vary in complexity ๐Ÿ‘

TheWCKD commented 3 years ago

Amazing update and a pure example on how minimalism, even though it's tough to achieve, can be such a fresh breath of air for bloc and not only that! Thanks, @felangel!

amr-eniou-3r commented 3 years ago

@felangel It is very clean, I would like to see a similar way to the Freezed library

state.When/MaybeWhen and state.Map/MaybeMap and of course state.copyWith too

felangel commented 3 years ago

@felangel It is very clean, I would like to see a similar way to the Freezed library

state.When/MaybeWhen and state.Map/MaybeMap and of course state.copyWith too

Thanks! You should still be able to use freezed with when, copyWith, etc... with this approach

class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc() : super(MyState.initial()) {
    on<MyEvent>(_onEvent);
  }

  void _onEvent(MyEvent event, Emit<MyState> emit) async {
    event.when(
      a: () => emit(state.copyWith(...)),
      b: () => emit(state.copyWith(...)),
      ...
  }
}
amr-eniou-3r commented 3 years ago

@felangel I mean merge freezed magic to your magic ๐Ÿ˜„ , no problem we can still use it separately

jorgecoca commented 3 years ago

This comment will add zero value to the proposal (which, by the way, amazing job on making the message clear!)... but I can't wait to see sealed classes/unions and data classes baked into Dart! These API changes + those language changes will be ๐Ÿ’ฏ!

elias8 commented 3 years ago

Nice. That will make the code less boilerplate and more readable at the same time for bloc.

This will be like reading plain English!

on<Increment>(_onIncrement);
on<Decrement>(_onDecrement);

But I see that the cubit code is changed to a bloc by having an event instead of public methods


class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

 void increment() => emit(state + 1);
} 

Becomes

```dart
 abstract class CounterEvent {}
 class Increment extends CounterEvent {}

 class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>(_onIncrement);
  }

  void _onIncrement(Increment event, Emit<int> emit) {
    emit(state + 1);
  }
}

So how is that not going to break cubits?

These changes will obviously be breaking changes that impact blocs (cubits will remain unaffected) so they would all be within the scope of a v8.0.0 release.

Or do I miss something?

Thanks! Sorry for the confusion, the cubit code will not change at all. I was just trying to show how these changes would make it easier to convert a cubit into a bloc at a later time if you decided you wanted to switch. Let me know if that helps ๐Ÿ‘

elias8 commented 3 years ago

Understood ๐Ÿ‘

adrianflutur commented 3 years ago

That's great ๐Ÿ‘Œ Can we also maybe rename the Emit class to Emitter? Honestly Emit sounds more like a verb, like an action to be done (e.g. 'change', 'set'), not really something which provides me with that action (e.g. 'changer', 'setter') ๐Ÿ˜

felangel commented 3 years ago

That's great ๐Ÿ‘Œ Can we also maybe rename the Emit class to Emitter? Honestly Emit sounds more like a verb, like an action to be done (e.g. 'change', 'set'), not really something which provides me with that action (e.g. 'changer', 'setter') ๐Ÿ˜

Thanks! Emit would just be a typedef which would look something like:

typedef Emit<State> = void Function(State);
willlockwood commented 3 years ago

I like this! Mainly for reasons that others have already explained many times over now โ˜๏ธ

But I have a questionโ€”how would the library behave when multiple handlers are defined for the same event type? Would both be called (and in what order), or would one override the other? Would that be prevented somehow (I'm struggling to imagine how that could be done AOT)?

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<Increment>((event, emit) => emit(state - 1));
  }
}

This is the only thing I can come up with that I think represents something less intuitive about this approach than having one mapEventToState method that itself is always is called predictably (even if the internals can definitely get messy for newcomers).

Anyway, I still see this is an improvement and would be excited to work with this APIโ€”just curious how you'd plan to approach that!

willlockwood commented 3 years ago

Also kinda similar-ish question (and yes, I know that I'm offering you use cases that would likely be explicitly discouraged/practically never tried in the first place; just trying to give you stuff to consider):

I imagine this would be handled internally by registering handlers that check for a match on the event type before calling the correct callbacks, right? If so, how would the library deal with event type inheritance (because an IncrementTwice event could return true for both event is IncrementTwice and event is Increment)?

abstract class CounterEvent {}
class Increment extends CounterEvent {}
class IncrementTwice extends Increment {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<IncrementTwice>((event, emit) => emit(state + 2));
  }
}
felangel commented 3 years ago

I like this! Mainly for reasons that others have already explained many times over now โ˜๏ธ

But I have a questionโ€”how would the library behave when multiple handlers are defined for the same event type? Would both be called (and in what order), or would one override the other? Would that be prevented somehow (I'm struggling to imagine how that could be done AOT)?

abstract class CounterEvent {}
class Increment extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<Increment>((event, emit) => emit(state - 1));
  }
}

This is the only thing I can come up with that I think represents something less intuitive about this approach than having one mapEventToState method that itself is always is called predictably (even if the internals can definitely get messy for newcomers).

Anyway, I still see this is an improvement and would be excited to work with this APIโ€”just curious how you'd plan to approach that!

Thanks for the feedback and great question! Iโ€™m open to suggestions but was leaning towards throwing a StateError if multiple handlers are registered for the same event type. Let me know what you think ๐Ÿ‘

felangel commented 3 years ago

Also kinda similar-ish question (and yes, I know that I'm offering you use cases that would likely be explicitly discouraged/practically never tried in the first place; just trying to give you stuff to consider):

I imagine this would be handled internally by registering handlers that check for a match on the event type before calling the correct callbacks, right? If so, how would the library deal with event type inheritance (because an IncrementTwice event could return true for both event is IncrementTwice and event is Increment)?

abstract class CounterEvent {}
class Increment extends CounterEvent {}
class IncrementTwice extends Increment {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<IncrementTwice>((event, emit) => emit(state + 2));
  }
}

Another great question ๐Ÿ‘

Still working through the implementation details but ideally inheritance should be taken into consideration and all handlers of type T where T is E should be invoked when an event E is added. Hope that helps ๐Ÿ‘

willlockwood commented 3 years ago

was leaning towards throwing a StateError if multiple handlers are registered for the same event type

Yeah that sounds like it would make the most sense. Restricting the ability to fall into this implementation in the first place definitely takes any of the ambiguity away, which I imagine is best ๐Ÿ‘๐Ÿป

ideally inheritance should be taken into consideration and all handlers of type T where T is E should be invoked when an event E is added

Interesting! I haven't thought much about it at all, but at first blush seems the most reasonable as well. And then I imagine the multiple callbacks would be called in the order that they're defined/registered?

That's all from me, I thinkโ€”big fan of the idea and hope I can get working with it soon!

felangel commented 3 years ago

And then I imagine the multiple callbacks would be called in the order that they're defined/registered?

Yup thatโ€™s what I was thinking ๐Ÿ‘

That's all from me, I thinkโ€”big fan of the idea and hope I can get working with it soon!

Thanks! Let me know if you have any other questions or suggestions and Iโ€™ll do my best to get an early preview of the API ready sometime this week (hopefully) so that people can start to mess around with it.

kranfix commented 3 years ago

@felangel how could we avoid race conditions?
https://github.com/felangel/bloc/issues/2526#issuecomment-860886596

class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  WeatherBloc(this.weatherRepository) : super(WeatherInitial()) {
    on<WeatherRequested>(_onWeatherRequested);
  }

  final WeatherRepository weatherRepository;

  void _onWeatherRequested(WeatherRequested event, Emit<WeatherState> emit) async {
    emit(WeatherLoadInProgress());
    try {
      final weather = await weatherRepository.getWeather(event.latLng);
      emit(WeatherLoadSuccess(weather);
    } catch (_) {
      emit(WeatherLoadFailure());
    }
  }
}

This async method _onWeatherRequested can't say when it is finished, then if I send twice the event WeatherRequested, there is no mechanism for awaiting the end of the process of the first event.

adrianflutur commented 3 years ago

That's great ๐Ÿ‘Œ Can we also maybe rename the Emit class to Emitter? Honestly Emit sounds more like a verb, like an action to be done (e.g. 'change', 'set'), not really something which provides me with that action (e.g. 'changer', 'setter') ๐Ÿ˜

Thanks! Emit would just be a typedef which would look something like:

typedef Emit<State> = void Function(State);

Right, typedef sorry ๐Ÿคญ I've seen this kind of approach inside the framework when using StatefulBuilder (you get a setState function in the callback, which is a typedef named StateSetter) https://api.flutter.dev/flutter/widgets/StatefulBuilder-class.html

But if it's not possible then no worries, great job anyway! ๐Ÿ’ช

mtwichel commented 3 years ago

Hey @felangel, just throwing in my two cents.

I may be the only dissenter but the amount of refactoring for big projects really makes me wary of this change. I feel this level of change ideally would only come if it's strictly needed. From my point of view I'm not convinced it is necessary.

To address point 1, I may be out of the loop but having nested generator like in the linked issue seems like a real anti-pattern and could be solved with some refactoring?

For 2, I am empathetic to the learning curve, but I also think Cubit is a much more approachable bloc-like thing that could be encouraged to be used for beginners. Kind of a "only use a bloc if you need to, otherwise use cubit".

For 3, I think things like the vscode extension and mason makes the boilerplate argument less impactful. I don't see the current implantation as overly verbose, and again, cubit is a nice alternative if that stuff really matters to you.

I again want to stress I'm not against it in theory; in fact, I think it's a nice readable syntax and I love that. I mainly just don't think the advantages warrant such a large breaking change. To me it seems like a bandaid solution to an edge case bug in dart, as well as syntax changes that cubit already handles well. I'm happy to be wrong about both those views though.

I also can't stress enough how grateful I am for this package and all the time everyone invests in it โค๏ธ thank you ๐Ÿ™

omartinma commented 3 years ago

Awesome explained! Willing to see the changes soon :D

TheAlphamerc commented 3 years ago

Well the proposal looks promising and easy to understand for newcomers.

fabriziocacicia commented 3 years ago

Nothing against the proposal but I was just thinking that as Bloc is evolving I feel that it is moving more and more away from the BLoC pattern (Input Sink -> Output Stream).

I'm not saying that this is wrong or should be avoided, at the end of the day if the tool works it's ok but it was just a consideration. I don't know if it matters to someone or not.

kranfix commented 3 years ago

@fabriziocacicia The BLoC pattern never was about "Input Sink -> Output Stream". It was about an event handler pattern base on state machines and the implementation was based on Streams.

fabriziocacicia commented 3 years ago

@kranfix I don't think so.

The bloc library was implemented since the beginning as a sort of a state machine, but if you saw the talk where the BLoC pattern was presented (Google IO ยด18) it was presented as a component with an input Sink and an output Stream.

Anyway, don't turn this issue into a debate about the essence of the BLoC pattern.

narcodico commented 3 years ago

@kranfix the most important spec about the BLoC pattern was in fact relying solely on sinks and streams. ๐Ÿ˜‰

@fabriziocacicia the bloc is using a Sink for adding events and a Stream for outputting states, how is that moving away from the BLoC pattern? ๐Ÿ˜Š

orestesgaolin commented 3 years ago

I strongly agree with point of @mtwichel

For 2, I am empathetic to the learning curve, but I also think Cubit is a much more approachable bloc-like thing that could be encouraged to be used for beginners. Kind of a "only use a bloc if you need to, otherwise use cubit".

My main concerns:

  1. What would be the migration strategy? Would a person have to manually convert mapEventToState to on<Event> syntax? How long would the mapEventToState be supported?
  2. How would the on<Event> behave if multiple events are added to the bloc? Would the behavior be identical to how current implementation works assuming the only change would be around mapEventToState and bloc's constructor?
  3. Would it still be possible to transform events with stream transformations like debunce or filter?
  4. How would event processing behave if the action in the mapEventToState needs to be awaited and events are being added much more frequently? See example below. The fact that the generator needs to finish processing before taking any new event into mapEventToState has been a source of many assumptions in the past when it comes to the UI etc. I wonder if this would remain unchanged.

Frankly, this change seems a bit too impacting to be worth it. Recently I had to migrate one app with dozens of bloc from version 6 to version 8 and until now I haven't migrated all the tests :/

https://user-images.githubusercontent.com/16854239/122046949-d6330100-cddf-11eb-8b04-76ba684505ea.mp4

import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

abstract class CounterEvent {}

class IncrementCounter extends CounterEvent {}

class CounterState {
  const CounterState(this.value);
  final int value;
}

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(0));

  @override
  Stream<CounterState> mapEventToState(
    CounterEvent event,
  ) async* {
    if (event is IncrementCounter) {
      yield* _mapIncrementCounterToState(event);
    }
  }

  Stream<CounterState> _mapIncrementCounterToState(
      IncrementCounter event) async* {
    await Future.delayed(Duration(seconds: 5));
    yield CounterState(state.value + 1);
  }
}

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: BlocProvider(
        create: (context) => CounterBloc(),
        child: Scaffold(body: MyHomePage()),
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<CounterBloc, CounterState>(
      builder: (context, state) {
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('${state.value}'),
              ElevatedButton(
                onPressed: () {
                  context.read<CounterBloc>().add(IncrementCounter());
                },
                child: Text('+1'),
              ),
              TimerText(),
            ],
          ),
        );
      },
    );
  }
}

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

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

class _TimerTextState extends State<TimerText> {
  late Timer timer;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(Duration(seconds: 1), (timer) {
      setState(() {});
    });
  }

  @override
  void dispose() {
    timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text('${timer.tick}');
  }
}
fabriziocacicia commented 3 years ago

@narcodico I thought that because the proposal removes completely any notion of Streams to the user, moving from streams to callbacks, changing completely the pattern. But, again, I'm not saying that this is bad for the package. I was just talking about the relationship with the BLoC pattern. :)

fabriziocacicia commented 3 years ago

Maybe @orestesgaolin with his comment addressed with more details my concern about moving away too much from the BLoC pattern. Removing completely any notion of Sinks/Streams could be controversial. On one hand, it could lower the entry barrier for new users since they are not easy topics. On the other hand, those who are aware and have chosen the package because of its nature (i.e. a design pattern for using and handling streams) could find moving away from them problematic.

Lonli-Lokli commented 3 years ago

Will it affect bloc.js as well?

cmc5788 commented 3 years ago

@kranfix @felangel

This async method _onWeatherRequested can't say when it is finished, then if I send twice the event WeatherRequested, there is no mechanism for awaiting the end of the process of the first event.

  1. How would the on behave if multiple events are added to the bloc? Would the behavior be identical to how current implementation works assuming the only change would be around mapEventToState and bloc's constructor?
  2. Would it still be possible to transform events with stream transformations like debunce or filter?
  3. How would event processing behave if the action in the mapEventToState needs to be awaited and events are being added much more frequently? See example below. The fact that the generator needs to finish processing before taking any new event into mapEventToState has been a source of many assumptions in the past when it comes to the UI etc. I wonder if this would remain unchanged.

These are good points.

The default behavior of bloc today is such that I think there is (probably) no change needed because the on handlers would queue and operate in order, because transformEvents is implemented under the hood with asyncExpand, which queues all work on the stream and runs it in order.

In the past it was possible to override transformEvents to use operators (such as switchMap or exhaustMap) to guarantee that a new event either cancels ongoing downstream or ignores until downstream is complete to avoid most types of race conditions.

I suppose with this API change transformEvents would stay the same and could still be used this way? It seems like a change to the behavior of the event processing logic itself might intuitively be outside the scope of this API change, but is that the case?

cmc5788 commented 3 years ago

@felangel Oh, one point related to my above comment that this reminds me of. More fun related to how Dart streams work ๐Ÿ˜„

Here's a potential "gotcha" with this approach I've experienced related to the cancel my operation when new ones start via switchMap use case:

@override
Stream<Transition<Event, State>> transformEvents(
  Stream<Event> events,
  TransitionFunction<Event, State> transitionFn,
) {
  // Assume I want new events to always cancel previous ones ...
  return events.switchMap(transitionFn);
}

void _onWeatherRequested(WeatherRequested event, Emit<WeatherState> emit) async {
  emit(WeatherLoadInProgress());
  try {
    // A new WeatherRequested event occurs while this `await` is happening ...
    final weather = await weatherRepository.getWeather(event.latLng);
    // At this point, since switchMap is used above, we'd expect the new event
    // to cancel the currently-running operation. The downstream is canceled
    // correctly, but since this just a function with an emitter, this event
    // will actually be emitted even though the stream running it is canceled.
    // ---
    // Stream cancellation doesn't stop async functions from running for side 
    // effects, and in this case `emit` is a side effect, since it doesn't
    // actually propagate its result by return value it will still happen.
    emit(WeatherLoadSuccess(weather);
  } catch (_) {
    emit(WeatherLoadFailure());
  }
}

Basically, the problem is that now emitting state is a side effect instead of an actual event in the stream, so it can break cancellation operators (such as switchMap) which are commonly used if it's not implemented carefully with some test cases to prevent the undesirable behavior of emitting from an operation that's already cancelled.

You need some kind of check if I'm canceled logic in the emitter itself. I've managed to get this working in a similar implementation but mileage may vary.

Edit:

switchMap is one example, but without check-for-cancellation behavior if state emission is a side-effect, any other effect of setting the state will have to guarded as well. Observer callbacks happening after a bloc is disposed, maybe? Not really sure all the implications but just something to generally be careful about.

jacobaraujo7 commented 3 years ago

why not this:

  on<Increment>((emit) => emit(state + 1));

without the Event property?

To facilitate testing, I could pass the state in the function.

  on<Increment>((emit, state) => emit(state + 1));
amr-eniou-3r commented 3 years ago

@jacobaraujo7 We need the event parameters like event.isDoubleIncrease or event.city and so on

felangel commented 3 years ago

Hey @felangel, just throwing in my two cents.

I may be the only dissenter but the amount of refactoring for big projects really makes me wary of this change. I feel this level of change ideally would only come if it's strictly needed. From my point of view I'm not convinced it is necessary.

To address point 1, I may be out of the loop but having nested generator like in the linked issue seems like a real anti-pattern and could be solved with some refactoring?

For 2, I am empathetic to the learning curve, but I also think Cubit is a much more approachable bloc-like thing that could be encouraged to be used for beginners. Kind of a "only use a bloc if you need to, otherwise use cubit".

For 3, I think things like the vscode extension and mason makes the boilerplate argument less impactful. I don't see the current implantation as overly verbose, and again, cubit is a nice alternative if that stuff really matters to you.

I again want to stress I'm not against it in theory; in fact, I think it's a nice readable syntax and I love that. I mainly just don't think the advantages warrant such a large breaking change. To me it seems like a bandaid solution to an edge case bug in dart, as well as syntax changes that cubit already handles well. I'm happy to be wrong about both those views though.

I also can't stress enough how grateful I am for this package and all the time everyone invests in it โค๏ธ thank you ๐Ÿ™

Thanks for the feedback, I really appreciate it! ๐Ÿ™

To address point 1, I may be out of the loop but having nested generator like in the linked issue seems like a real anti-pattern and could be solved with some refactoring?

In my opinion, this isn't an anti-pattern. I think it's fairly natural to decompose mapEventToState into multiple private handlers which can each be async generators. I agree that it would be nice to have the bug fixed in Dart but it seems like we shouldn't count on that happening anytime soon.

Regarding learning curve and boilerplate, I agree that they are less important and I would not propose making breaking changes just for the sake of reducing boilerplate.

A potential alternative to make the change less breaking would be to modify the signature of mapEventToState to look like:

void mapEventToState(Event event, Emit<State> emit) {...}

This would address the predictability issues and limit the scope of the changes. I'm not a fan of the naming and would rather have a slightly more breaking change personally in order to refine the API but I realize cost of migration is a major consideration.

The migration for the current proposal could be at least partially automated by a codemod and mapEventToState could be deprecated to allow a smoother transition. Just want to understand if the concerns are regarding the proposal itself or mainly the impact in terms of breaking changes, thanks! ๐Ÿ™

felangel commented 3 years ago

@cmc5788 @kranfix @orestesgaolin regarding the behavior, I'd like to hear from you -- what do you prefer? We could retain the same behavior (asyncExpand) but it's come up multiple times that the more natural default would be to process events as soon as they are added to avoid building up a large buffer which clogs up the bloc (if the user spams a button for example). I'd like to leave this open for discussion and gather more feedback.

@Lonli-Lokli

Will it affect bloc.js as well?

Ideally we would also migrate bloc.js to align with the current API if we decide to move forward with this proposal ๐Ÿ‘

mtwichel commented 3 years ago

Thanks for the reply @felangel

In my opinion, this isn't an anti-pattern. I think it's fairly natural to decompose mapEventToState into multiple private handlers which can each be async generators. I agree that it would be nice to have the bug fixed in Dart but it seems like we shouldn't count on that happening anytime soon.

That's fair enough. I've never used it that way but I could see that being a good way to use it. This alone is probably enough reason to make the change - as you stated "one of the core principles/goals of the bloc library is to be predictable". If this bug in Dart is causing issues for predictability, I agree that should be addressed.

A potential alternative to make the change less breaking would be to modify the signature of mapEventToState...

I totally agree that if you're making the change, we should go all the way. I think modifying the signature is almost as much work as refactoring to the on<Event> syntax.

Just want to understand if the concerns are regarding the proposal itself or mainly the impact in terms of breaking changes, thanks!

My only concern is breaking changes and how much work it will be to refactor. In the end, that's probably not a good enough reason to not do it as it's clearly cleaner code. But I'm now really convinced that the change should be made for predictability alone.

orestesgaolin commented 3 years ago

We could retain the same behavior (asyncExpand) but it's come up multiple times that the more natural default would be to process events as soon as they are added (...)

My point of view may be slightly biased as I used flutter_bloc from its very early versions, and before I used manual blocs, so I'm used to this queueing behavior. Thus, retaining it would be my ideal solution, even with syntax changes. In a day-to-day development it's not an issue, but I recall cases where I was actually aware and reliant on this particular approach e.g. when yielding loading state.

However, there were cases where this behavior was undesired, and thanks to introduction of Cubit I was able to achieve desired "instant" handling of events/functions.

In summary I would be really happy to see syntax change, but retaining the previous behavior would be a high priority for me. For instance, I don't recall writing tests for situations where two events would be added at the same time and 2nd of them would finish processing earlier than the order of events would suggest.

cmc5788 commented 3 years ago

@orestesgaolin

My point of view may be slightly biased as I used flutter_bloc from its very early versions, and before I used manual blocs, so I'm used to this queueing behavior. Thus, retaining it would be my ideal solution, even with syntax changes. In a day-to-day development it's not an issue, but I recall cases where I was actually aware and reliant on this particular approach e.g. when yielding loading state.

However, there were cases where this behavior was undesired, and thanks to introduction of Cubit I was able to achieve desired "instant" handling of events/functions.

In summary I would be really happy to see syntax change, but retaining the previous behavior would be a high priority for me. For instance, I don't recall writing tests for situations where two events would be added at the same time and 2nd of them would finish processing earlier than the order of events would suggest.

Maybe I'm an outlier, but I tend to make heavy use of operators to avoid the problem of queueing up a lot of async operations in response to button presses or something like that, so I rarely accept the default behavior.

My personal preference, even if it were a little more verbose, would be to provide an API that forced a developer to semantically indicate the async processing behavior of events, instead of relying on (or even allowing) a default.

I don't really think of infinite-queueing as a reasonable default unless everything you're doing is instantaneous. It causes backpressure, and it may only appear to be working correctly but causing the sort of bugs that manual QA testers usually expose by pressing a bunch of buttons repeatedly, or may only occur when network conditions are bad in the field ๐Ÿ˜…

So in summary, I don't know if I'd advocate for a change to default behavior, but I might advocate for not having a default behavior at all. I tend to think that developers should explicitly opt into whatever the async behavior of their event is in the case of multiple overlapping async things, to avoid unexpected behavior.

A good semantic reference for async task types might be http://ember-concurrency.com/docs/task-concurrency/

If I had to pick a default, I'd probably go with flatMap or run the events concurrently, which lines up with the implementation for ember-concurrency as well.

orestesgaolin commented 3 years ago

So in summary, I don't know if I'd advocate for a change to default behavior, but I might advocate for not having a default behavior at all. I tend to think that developers should explicitly opt into whatever the async behavior of their event is in the case of multiple overlapping async things, to avoid unexpected behavior.

A good semantic reference for async task types might be http://ember-concurrency.com/docs/task-concurrency/

Woah, the linked documentation is awesome. Having seen that I would really love to see ability to configure how the events should be handled, but this might be a separate feature request :) So whenever I need the "current default' behavior I could provide a transformation function or a setting that would allow for that.