How to handle one-time UI events with Bloc in Flutter
How to handle one-time UI events with Bloc in Flutter
How to handle one-time UI events with Bloc in Flutter

How to handle one-time UI events with Bloc in Flutter

Flutter

Flutter

Flutter

One-time UI events might get tricky. Might side effects be the right way?

13. 3. 2023

The Flutter BLoC pattern is a popular and efficient way of managing a state in Flutter apps. It allows us to separate business logic from the user interface, making managing applications' dynamic and complex states easier. However, as with every similar tool, it has challenges and can sometimes be difficult to use in specific scenarios. Today, we’re going to focus on handling one-time events, such as displaying a confirmation dialog or a snack bar. In general, any event that should only be shown to the user once without affecting the state falls into this category.

In this post, all examples use the Bloc library, particularly the bloc and flutter_bloc packages.

Typical use of one-time events in blocs

Let's demonstrate a simple app that loads items and lets users like them. The app has two events. The first one relates to loading the items. You simply tap on the load-more button, and if the process succeeds, an updated list with new items is emitted. If an error occurs, a red snack bar should appear to inform the user. The second event is a functionality that allows users to like an item. Here, you tap the heart icon next to each item. Liking an item turns it red and makes the snack bar appear. Users can remove the like by tapping the heart icon again.

netglade ui app

Target app example (NetGlade)

As the app is very simple, it is driven by a single ItemsBloc bloc with single state ItemsState with variables:

  • a list of items where each item contains its name and a liked flag

  • a bool determining if the loading failed

  • an optional likeInfo object that holds whether the item was liked or unliked

Similarly, the bloc has 2 events: LoadItems and LikeItem. An event to like an item has an order (index) of the item in the list.

Once the user taps a load-more button, an event LoadItems is sent to the bloc. In response, the bloc sends the request to the API. If the call is successful, the list should be loaded or updated. If some error occurred, we should see a red snack bar with information about that.

When a user taps a like or an unlike icon, an event LikeItem is sent. In response, the bloc emits the state with set likeInfo parameter, reflecting whether the like or unlike happened, and sets the item's name to the corresponding string.

Take a look at the implementation of loading items:

on<LoadItems>((event, emit) {
  late final List<Item> newItems;
  try {
    newItems = _fetchItemsFromApi(count: state.items.length);
  } on Exception catch (_) {
    emit(state.copyWith(loadingFailed: true));
    return;
  }

  emit(
    state.copyWith(
      items: List.of(state.items)..addAll(newItems),
      loadingFailed: false,
    ),
  );
});

And here’s one for liking an item:

on<LikeItem>((event, emit) {
  final newItems = state.items
      .mapIndexed(
        (index, element) => index == event.order
        ? element.copyWith(liked: !element.liked)
        : element,
  )
      .toList();
  final item = newItems[event.order];

  emit(
    state.copyWith(
      items: newItems,
      likeInfo: LikeInfo(
        isLike: item.liked,
        itemName: item.name,
      ),
    ),
  );
});

To show the snack bars, we use BuildConsumer, which allows us to add a listener builder that is only called once, and we can execute non-build code there. It would look like this:

listener: (context, state) {
  if (state.loadingFailed) {
    // show red snack bar about loading failed
  }

  if (state.likeInfo != null) {
    // show snack bar about like/unline
  }
}

What’s wrong with it?

As we know, displaying a list of items is a piece of cake. The problems might come later when we want to add additional actions, mainly when producing effects other than displaying them on a screen.

Because we are using copyWith on ItemsState rather than manually creating a new state each time, managing the internal states of these actions or one-time parameters is quite tricky. And we often cannot emit entirely new states because each event only needs to affect a particular part of the state. In the example, we use two action parameters for the state, bool loadingFailed and LikeInfo? likeInfo.

As you can see on the gif below, the error snack bar is not shown when the error occurs the second time because the internal state (items, boolean variable) does not change. That is because we want to use Equatable, which makes the new state equal by overriding == and hashCode. Generally, you want to use Equatable for their perks, as bloc only rebuilds widgets when new states are emitted, not similar. In Flutter, UI is built based on a state, which makes this option quite reasonable. And when the state is the same, there is no need to rebuild. Similarly, if the loading of items fails (and a red snack bar is displayed) and the like status changes for one item afterwards, the loading failed snack bar is shown again. This is happening because the loading failed boolean variable has not been reset.

netglade ui app

Example with issues due to a snack bar controlled by bloc states generated by copyWith (NetGlade)

Potential solutions of one-time events problematics

The problems mentioned above have several potential solutions, each with their advantages and disadvantages. Here are some of them:

1. Reset all the action parameters at the end of every bloc action

Whenever a question is liked, or an answer is selected, we can simply emit an additional ItemsState state with all the action parameters set to their default values (null or false). Unfortunately, the reset leads to an unnecessary UI rebuild, and we need to remember to reset the state each time the action is performed.

2. Use a separate bloc for every action

In our sample app, we could split it into 2 blocks. A bloc for loading list of items and a bloc for handling likes for each item. Therefore for 10 items in a list, we would need 1 bloc for loading items and 1 instance for each item, meaning 11 in total. This solution is nice and clean as it decouples the logic. However, in the case of a minor feature, it may be unnecessarily complex, and it adds a lot of boilerplate code. Decoupling the logic might also be more complicated than it might seem sometimes.

3. Use a modified copyWith method to create new ItemsState states when performing any action

This can be done in two ways. The first is to create a separate copy-with-based method for each action parameter, like copyWithLikedItem, that sets the likeInfo parameter and resets all the other action parameters to default value. One drawback of this solution is that new actions require creating a separate method to generate a new ItemsState state. The other approach is to create some universal method like copyWithReset that would set other one-time parameters to their default value every time, except for arguments passed to the method. For this approach, we cannot control individual parameters efficiently, and if we want to keep something on every new emit, we are in trouble. As the number of actions and types of loaded data can increase, this approach can become difficult to manage.

4. Take advantage of additional one-time events stream

Sometimes one-time events are also called side effects, presentation events, etcetera. This stream is separated from the main bloc's state stream and is handled by a dedicated listener widget. We will take a close look at this approach in the following chapters.

netglade ui

Illustration of side effects (NetGlade)

Handling one-time events

As previously mentioned, we can extend the bloc and add a stream to it. That can be achieved by either a subclass or a mixin. And to produce a one-time event, we simply add it to this stream. Similar to listening to standard bloc state stream, we can create a particular listener widget that listens to the new stream events and reflect them accordingly. This approach is particularly well-suited for one-time actions, like showing dialogs or snack bars, as it does not impact the bloc's state. In the case of Bloc, it is best to work around extending the BlocBase using a mixin, so you can work with both blocs and cubits. Extending the Bloc and Cubit classes is possible, but you must duplicate the code.

By deploying this concept, we can solve our one-time events problems by creating 2 new one-time events corresponding with the app actions — ItemLikedEffect and LoadingFailedEffect. Instead of emitting new states, we produce one-time events in the bloc handler.

Here’s an implementation of item loading changes only in the catch block:

on<LoadItems>((event, emit) {
  late final List<Item> newItems;
  try {
    newItems = _fetchItemsFromApi(count: state.items.length);
  } on Exception catch (_) {
    produceSideEffect(LoadingFailedEffect()); // <---
    return;
  }

  emit(
    state.copyWith(
      items: List.of(state.items)..addAll(newItems),
      loadingFailed: false,
    ),
  );
});

The implementation of liking is further simplified by removing likeInfo arguments from the copyWith method and producing the side effect afterwards. The emit must remain, so we can display a different like icon colour and shape.

on<LikeItem>((event, emit) {
  final newItems = state.items
      .mapIndexed(
        (index, element) => index == event.order 
            ? element.copyWith(liked: !element.liked) 
            : element,
      )
      .toList();
  final item = newItems[event.order];

  emit(state.copyWith(items: newItems)); // <--
  produceSideEffect(ItemLikedEffect(name: item.name, liked: item.liked)); // <--
});

Then we use the listener for side effects above the builder for states, move the listener from bloc consumer there, and modify to check for the classes:

SideEffectListener<ItemsBloc, ItemsEffect>(
  listener: (context, sideEffect) {
    if (sideEffect is LoadingFailedEffect) {
      // show red snack bar about loading failed
    }

    if (sideEffect is ItemLikedEffect) {
      // show snack bar about like/unline
    }
  },
  child: ..., // BlocBuilder etc.
)

Using one-time events or side effects, you name it, we do not have to modify our bloc states and keep the logic pretty simple while avoiding a lot of boilerplates. There is no risk of data loss or undesired showings of snack bars or dialogs.

Several libraries already implement the concept of one-time events, for example, bloc_presentation, side_effect_bloc, or bloc_effects.

Final thoughts

One might ask which is the better option: split blocs into multiple small ones for each action or use side effects? Well, as usual with coding, the answer isn’t so clear-cut.

Essentially, it's all about trade-offs. Both solutions might work, but you should consider if displaying one snack bar or dialog is worth the effort of separating a bloc into two, three, or even more. And while using side effects couples the bloc with UI more than it should, it might be an easy and transparent way to go.

Try both approaches for yourself and play with them. Then you can decide your preference. We suggest not enforcing one way over the other in the whole project. In some places, it might be better to use side effects, while others will fare better with splitting the bloc.

Sources

Recipes: Show SnackBar with BlocListener | bloclibrary.dev

Add the concept of side effects | felangel - Github

Side-effects (events) without overriding state | felangel - Github

Podobné články

Podobné články

Podobné články