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

How to implement a 'wizard' like state machine which collects data as it goes further/deeper #1475

Closed wujek-srujek closed 3 years ago

wujek-srujek commented 4 years ago

Hei Felix. To start with, sorry for the longish question, but I wanted to make sure I describe the use case correctly, I hope you don't fall asleep reading it!

I have a question about BLoC usage. Our use case is the following (simplified, our actual use case is more complex and the 'wizard' has more steps): we first need to choose a country (country list loaded from backend), then the next step is to choose a state (e.g. California in US, Bavaria in DE) (also loaded from backend), and then a city within that state (also from backend). All of these pieces of information are visible in one screen (imagine the ASCII-art below is a ListView with three items that show what is currently selected and clicking it navigates to the country/state/city chooser screen, symbolized by the arrow):

------------
Country   ->
------------
State     ->
------------
City      ->
------------
------------
Button
------------

Only by selecting preceding data is the user allowed to pick the next one; until then, its ListItem is disabled. E.g. you cannot chose a state or city if the country is not selected, and until then, the state and city items are disabled. (Once all is selected, you can change the country and the rest will have to be reset, but let's leave it for now.)

Once everything is selected, the button (otherwise disabled) can be pressed, and it invokes some logic which uses all data, so we need to preserve the country, state, and city until the very end.

As the UI always needs to present all data, we need to 'collect' it in the states as we go 'deeper'. So, we have these states:

Initial CountrySelected (has country) ... StateSelected (has country and state) ... CitySelected (has country, state and city)

(There are actually more states symbolized by '...' above, e.g. between CountrySelected and StateSelected there are states waiting for the backend to get the state list, which also needs to keep the country so that the UI can show it, and the next state can be initialized with it within the BLoC.)

This makes is a bit cumbersome to use the BlocBuilder. For example, take the CountryListItem: we can fill it with data if the state is CountrySelected, StateSelected and CitySelected (and everything in between). Similarly, StateListItem can be filled for states StateSelected and CitySelected (and all states in between). Additionally, enabled statuses of the items also depend on various states.

This is a bit cumbersome, we always need an if with a couple of is <type> joined with an or, e.g. for StateListItem, within the BlocBuilder.builder callback:

var country;
var state;
if (state is StateSelected || state is CitySelected) {
  country = countryFromState(state); // a lot ifs with casts inside, ugly code
  state = stateFromState(state); // a lot ifs with casts inside, ugly code
}
// For other states, the item should still be visible, but might contain no
// data and might be disabled.
return StateListItem(
  state: state, // may be null
  enabled: country != null,
);

I hope the use case is clear ;d

We are not convinced this way is the right approach, it is getting very confusing in code already, with many, many ifs, and we are looking into different solutions:

Solution 1: we can add abstract state subtypes, like (pseudocode):

class WithCountry extends OurBaseState class WithCountryState extends WithCountry class WithCountryStateCity extends WithCountryState

and then have our states use them:

class CountrySelected extends WithCountry class StateSelected extends WithCountryState class CitySelected extends WithCountryStateCity

Pros: it does allow for simpler code and checks in BlocBuilder.builders, e.g. for StateListItem:

final country = state is WithCountry ? state.country : null;
final state = state is WithCountryState ? state.state : null;

return StateListItem(
  state: state,
  enabled: country != null,
);

Cons: it causes a proliferation of abstract state types, introduces a pretty complex inheritance hierarchy. Remember a class can extend only one superclass, maybe mixins could help but they are for code, not for introducing fields...

Solution 2: we define a custom field in our BLoC, let's call it CountryStateCity, with fields for all of country, state and city (all nullable), and it is a custom field and it accessible from the outside. When an event like CountrySelected(country) is added, the country is put into the CountryStateCity field by the BLoC (we can prevent clients from setting the field, and the type immutable so that only the BLoC can change it, of course).

Client code could look like this (again, example for StateListItem and its BlocBuilder.builder):

final bloc = getBloc(); // need a BLoC reference in `BlocBuilder.build`
final csc = bloc.countryStateCity;

final country = csc.country;
final state = csc.state;

return StateListItem(
  state: state,
  enabled: country != null,
);

Pros: it seems way simpler to implement.

Cons: it adds a custom field to the BLoC (i.e. introduces some kind of a 'parallel state' concept), and it introduces tighter coupling to the BLoC - even code within BlocBuilder.builder needs to look the BLoC up again to be able to use the custom field. Also, it makes the BLoC interface more complex, it is not a simple 'event stream input, state output'; it now has an additional field that all clients must use. I.e. some of us find it 'dirty' or 'impure' ;d.

Solution 3: use separate BLoCs, one for each piece of data, and for example once the country BLoC reaches a certain state, it notifies state BLoC that it can start loading from backend, and once state is chosen by the user, this BLoC notifies the city BLoC to start loading etc. Once city is selected, another BLoC, which manages the whole page, is notified that data is complete and enables the button (

If a user has chosen country, state and city, and chooses a new different country, the page BLoC tells the state and city BLoCs to 'reset'. If a user chooses a new city, the page BLoC tells the city BLoC to 'reset'.

Something like this, not completely thought through, came up with it while I was writing the other options ;d

What would be your recommendation for our 'wizard' use case?

felangel commented 4 years ago

Hi @wujek-srujek πŸ‘‹ Thanks for opening an issue!

I would probably recommend splitting the loading of the form content into a separate bloc so you would have something like:

class FormContentBloc extends Bloc<FormContentEvent, FormContentState> {
  FormContentBloc() : super(FormContentLoadInProgress());

  @override
  Stream<FormContentState> mapEventToState(FormContentEvent event) async* {
    if (event is FormContentStarted) {
      try {
        final content = await Future.wait([_getCountries, _getStates(), ...]);
        yield FormContentLoadSuccess(
          countries: content[0],
          states: content[1],
          ...,
        );
    } on Exception catch(_) {
      yield FormContentLoadFailure();
    }
  }
}

Then you can have a FormSelectionBloc like:

class FormSelectionBloc extends Bloc<FormSelectionEvent, FormSelectionState> {
  FormSelectionBloc(this._formContent) : super(FormSelectionState());

  final FormContent _formContent;

  Stream<FormContentState> mapEventToState(FormContentEvent event) async* {
    if (event is CountrySelected) {
      yield state.copyWith(selectedCountry: event.country);
    } else if (event is StateSelected) {
      yield state.copyWith(selectedState: event.state);    
    } else if (event is FormSubmitted) {
      yield state.copyWith(status: FormStatus.submissionInProgress);
      try {
        await _repo.submitForm(state.selectedCountry, state.selectedState, ...);
        yield state.copyWith(status: FormStatus.submissionSuccess);
      } on Exception catch (_) {
        yield state.copyWith(status: FormStatus.submissionFailure);
      }
    }
  }
}

I would highly recommend using the formz package to help you manage the state of you user inputs (especially when you want to perform local validation).

You can see check out the form validation example for more detail.

Hope that helps πŸ‘

wujek-srujek commented 4 years ago

Hi Felix, thanks for your answer and recommendation.

final content = await Future.wait([_getCountries, _getStates(), ...]);

I don't think we can use this as we can get state list only after a country is selected, otherwise we would have to get states for all the possible countries in the world (or at least those that our application knows), which would be even more dramatic for cities. This is a step by step process, hence my name 'wizard', which was a name for installation wizards etc. on Windows machines a few decades back ;d

We decided to go for the separate BLoCs solution, and each next one will listen to the previous one and decide when it needs to reset. We aren't ready yet but this solution seems very promising.

felangel commented 4 years ago

@wujek-srujek have you taken a look at the flutter_dynamic_form example? It sounds like it might be what you're looking for πŸ˜„

wujek-srujek commented 4 years ago

Hi @felangel , I looked at it, and while it works, I'm sorry to say I'm not a big fan. It just creates a single state type with all possible fields inside, so my widgets will always get the same state (different instances) and instead of checking for state type and acting/building accordingly, they will perform null checks. Don't get my wrong, it will work fine for a simpler app, but I don't think it will scale that well to our use case: more steps/data, with more repositories do fetch it from (i.e. more dependencies in the bloc, not just a single repo), more states (e.g. we need to react to failures by showing dialogs with a retry option, and whatever else the UI/UX people come up with) and so on. I'm afraid a single bloc would get unwieldy sooner than later. Not to mention how much we would need to mock and stub to be able to test a scenario from the beginning to the end.

I'm not sure, what do you think? Would you recommend this single-state approach instead of splitting the bloc into smaller ones, pretty much each one handling it's own repository, data fetching, errors etc. in separation? Similarly for testing: each step/bloc is tested separately, and the next one gets an event to start its own process. This approach was kinda growing on me ;d

felangel commented 4 years ago

@wujek-srujek yeah I agree it’s just a simple example which is why one bloc and one state works. If it were a larger more complex feature then splitting into multiple blocs would be more manageable. I can update this example to be more complex later today and you can have a look πŸ‘

wujek-srujek commented 4 years ago

Hi @felangel , thanks for being so helpful, but there is no need as far as I'm concerned, I think I'll manage ;d Thank you again for investing so much of your time and energy to help!

felangel commented 4 years ago

@wujek-srujek no problem but I definitely don’t want others to see the dynamic form example and try to extend it for complex cases with a single bloc. I want to ensure the examples will help developers build apps of all sizes/complexity so I will update the example to adopt a more scalable approach to avoid confusion πŸ‘

Thanks for bringing this up and don’t hesitate to reach out with any other questions. If you haven’t already, you can join the bloc discord server and chat with many community members about architecture/design as well as many other topics πŸ˜„

Reopening this issue to track the work to update the dynamic form example.

marcin-jelenski commented 4 years ago

@wujek-srujek prior to bloc we've had the same feeling using every MV* pattern - state transitions were becoming too complex for a more-complex scenarios. E.g. ticket buying process - splitting into multiple blocs made it even more difficult to understand and extend. That's definitely not a part of a bloc, but you might be interested in extending your logic to state machine graphs.

I've made a bloc extension which enumerates possible states and transitions between them: https://pub.dev/packages/state_graph_bloc

Also, there're many alternatives: https://pub.dev/packages?q=state+machine

felangel commented 3 years ago

Hey @wujek-srujek πŸ‘‹ Sorry for the delayed response but I finally managed to put together an example of how you can approach this using the flow_builder package at https://github.com/felangel/flow_builder/tree/master/example/lib/location_flow.

Let me know what you think πŸ‘

wujek-srujek commented 3 years ago

Hi @felangel I had a quick look and it seems like you also have 3 separate cubits for each part of the flow, i.e. separate cubits for coutry, state and city selection. If that's the case, and there is nothing that I missed, this is very similar to what we did as well, which means we were not wrong ;d