flutterbloc

How to show error messages or do success action while using Flutter Bloc during deletion?


I have one bloc, example PostsBloc that exposes one event LoadData and one event DeleteDataById and emits state of type LoadableBlocState<Post>

The PostsBloc is using the PostsRepository which makes simple http calls to get items and delete single item.

If the delete call gets a response statusCode !== 200 I want to show error message to the frontend (scaffold). I want to show an error every time the delete error happens. Also on consecutive errors.

On the other hand, if the deletion is successful (statusCode 200), I want to remove this post from the data, show success message in the ui (scaffold) and pop the navigator context / go back (when I delete the post from the post_screen.dart itself)

The simplified code of posts_bloc.dart

import 'package:my_app/src/app/app_runner.dart';
import 'package:my_app/src/common/common.dart';
import 'package:my_app/src/features/features.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class PostsBloc
    extends Bloc<LoadableBlocEvent, LoadableBlocState<PostsBlocValue>> {
  final PostsService _postsService;

  PostsBloc(this._postsService)
      : super(const LoadableBlocState.initial()) {
    on<LoadDataEvent>((event, emit) async {
      emit(const LoadableBlocState.loading());

      try {
        final r = await _postsService.getPosts();

        if (r.statusCode == 200) {
          emit(LoadableBlocState.loaded(PostsBlocValue(
            posts: _formPosts(r.parsedBody!),
            postFolders: r.parsedBody!.folders,
          )));
        } else {
          emit(LoadableBlocState.error(r));
        }
      } catch (e, stacktrace) {
        recordError(e, stacktrace);
        emit(LoadableBlocState.error(e));
      }
    });

    on<DeleteDataByIdEvent>(
          (event, emit) async {
        final stateData = state.data;
        if (stateData == null) {
          return;
        }

        final r = await _postsService.deletePost(event.itemId);

        if (r.statusCode == 200) {
          emit(LoadableBlocState.loaded(PostsBlocValue(
            posts: stateData.posts
              ..removeWhere((e) => e.id == event.itemId),
            postFolders: stateData.postFolders,
          )));
        } else {
          emit(LoadableBlocState.otherError(
            state,
            LoadableBlocError.withCode(
              action: LoadableBlocAction.delete,
              code: r.statusCode.toString(),
            ),
          ));
        }
      },
    );
  }
}

List<Post> _formPosts(GetPostsResponse getPostsResponse) {
  List<Post> posts = [];

  for (final el in getPostsResponse.posts) {
    posts.add(
      Post(
        id: el.id,
        name: el.name,
        entries: el.entries
            .map((re) =>
            postEntryDataToPostEntry(
              re,
              exercises: getPostsResponse.exercises,
              metrics: getPostsResponse.metrics,
            ))
            .toList(),
        fromUserId: el.fromUserId,
        folderId: el.folderId,
        unitsConfig: el.unitsConfig,
        createdAt: el.createdAt,
        updatedAt: el.updatedAt,
      ),
    );
  }

  return posts;
}

class PostsBlocValue {
  final List<Post> posts;
  final List<PostFolder> postFolders;

  PostsBlocValue({required this.posts, required this.postFolders});
}

I tried to hold a state for possible deletion errors

I created a state object which can hold "non fatal" errors and the widgets listen to them

loadable_bloc_state.dart

import 'package:equatable/equatable.dart';

class LoadableBlocState<T> extends Equatable {
  final bool loading;
  final T? data;
  final LoadableBlocError? error;

  const LoadableBlocState._({
    required this.loading,
    required this.data,
    required this.error,
  });

  const LoadableBlocState.initial()
      : this._(
          loading: false,
          data: null,
          error: null,
        );

  const LoadableBlocState.loading()
      : this._(
          loading: true,
          data: null,
          error: null,
        );

  const LoadableBlocState.loaded(T data)
      : this._(
          loading: false,
          data: data,
          error: null,
        );

  LoadableBlocState.errorLoading(Object error)
      : this._(
          loading: false,
          data: null,
          error: LoadableBlocError.withError(
              action: LoadableBlocAction.fetch, error: error),
        );

  LoadableBlocState.otherError(
      LoadableBlocState current, LoadableBlocError error)
      : this._(
          loading: current.loading,
          data: current.data,
          error: error,
        );

  bool isFetchFailed() =>
      error != null && error!.action == LoadableBlocAction.fetch;

  @override
  List<Object?> get props => [this.loading, this.data, this.error, this.error];
}

class LoadableBlocError extends Equatable {
  final LoadableBlocAction action;
  final String? code;
  final Object? error;

  const LoadableBlocError._(
      {required this.action, required this.code, required this.error});

  const LoadableBlocError.withCode(
      {required LoadableBlocAction action, required String code})
      : this._(action: action, code: code, error: null);

  const LoadableBlocError.withError(
      {required LoadableBlocAction action, required Object error})
      : this._(action: action, code: null, error: error);

  @override
  List<Object?> get props => [action, code, error];
}

enum LoadableBlocAction {
  fetch,
  delete,
  create,
  update,
}

loadable_bloc_event.dart

import 'package:equatable/equatable.dart';

class LoadableBlocEvent extends Equatable {
  @override
  List<Object?> get props => [];
}

class LoadDataEvent extends LoadableBlocEvent {}

class DeleteDataByIdEvent extends LoadableBlocEvent {
  final String itemId;

  DeleteDataByIdEvent({required this.itemId});

  @override
  List<Object?> get props => [this.itemId];
}

After implementing the bloc above I listened to the emitted states from my widget in two different ways

  1. Using Bloclistener and did not work because it triggers only 1 time and not the 2nd time if the state does not change. Use-case: User clicks "delete post" button, the first call fails and scaffold is shown fine. User clicks again, it fails again, state did not change and bloclistener is not triggered
  2. I used BlocConsumer and the result was the same as BlocListener

Successful approaches using bloc anti-patterns:

I can't find a proper solution respecting the bloc design pattern. I am posting below some solutions that work great

Approach 1

  1. Presentation layer, example: posts_view.dart a) calling directly the repository b) if code !== 200 then show scaffold, otherwise emit bloc event to remove the post data

Question: What do you think is the cleanest approach for supporting my use case while using blocs the way they are designed? I can't find a single proper solution on the entire web using blocs for such a simple, wanting to listen to delete/update/insert errors and showing them every time they happen and not only the single time.

This seems simple but is more complex. Another example: You trigger post deletion of post id 3, then open post_screen for post 1. You get error from post id 3 and show error in screen of 1. Probably have to send identifier as well. I tried that but bloclistener is trigger only one time on consecutive errors still.


Solution

  • I created one solution that meets all the bloc pattern requirements and only dispatches events and listens to state emits.

    I modified my loadable_bloc_state.dart to look like this:

    import 'package:equatable/equatable.dart';
    
    class LoadableBlocState<T> extends Equatable {
      final Action<T> action;
      final T? data;
    
      const LoadableBlocState._({
        required this.action,
        required this.data,
      });
    
      const LoadableBlocState.initial()
          : this._(
              action: const LoadingDataAction(
                inProgress: false,
                error: null,
              ),
              data: null,
            );
    
      const LoadableBlocState.loading()
          : this._(
              action: const LoadingDataAction(
                inProgress: true,
                error: null,
              ),
              data: null,
            );
    
      const LoadableBlocState.loaded(T data)
          : this._(
              action: const LoadingDataAction(
                inProgress: false,
                error: null,
              ),
              data: data,
            );
    
      LoadableBlocState.errorLoading(Object error)
          : this._(
              action: LoadingDataAction(
                inProgress: false,
                error: LoadableBlocError.withError(error: error),
              ),
              data: null,
            );
    
      bool isFetching() => action.type == ActionType.fetch && action.inProgress;
    
      bool isFetchFailed() =>
          action.type == ActionType.fetch && action.error != null;
    
      const LoadableBlocState.withAction({
        required Action<T> action,
        required T? data,
      }) : this._(action: action, data: data);
    
      @override
      List<Object?> get props => [
            this.action,
            this.data,
          ];
    }
    
    enum ActionType {
      fetch,
      update,
      delete,
      create,
    }
    
    abstract class Action<T> extends Equatable {
      final ActionType type;
      final bool inProgress;
      final LoadableBlocError<T>? error;
    
      const Action({required this.type, required this.inProgress, this.error});
    
      bool didSucceed() => error == null && !inProgress;
    
      @override
      List<Object?> get props => [type, inProgress, error];
    }
    
    class LoadingDataAction<T> extends Action<T> {
      const LoadingDataAction({required super.inProgress, required super.error})
          : super(type: ActionType.fetch);
    }
    
    class ItemAction<T, R extends Equatable> extends Action<T> {
      final String itemId;
      final R? req;
    
      const ItemAction._(
          {required this.itemId,
          this.req,
          required super.type,
          required super.inProgress,
          super.error});
    
      const ItemAction.success({
        required String itemId,
        R? req,
        required ActionType type,
      }) : this._(
              itemId: itemId,
              type: type,
              req: req,
              inProgress: false,
              error: null,
            );
    
      const ItemAction.error(
          {required String itemId,
          R? req,
          required ActionType type,
          required LoadableBlocError<T> error})
          : this._(
              itemId: itemId,
              type: type,
              req: req,
              inProgress: false,
              error: error,
            );
    
      @override
      List<Object?> get props => [itemId, req, type, inProgress, error];
    }
    
    class LoadableBlocError<T> extends Equatable {
      final String? code;
      final Object? error;
    
      // This timestamp can be used to trigger state changes and ensure that BlocListeners/Consumers are re-triggered. For instance, it helps in showing multiple delete error messages sequentially during consecutive retry attempts.
      final DateTime? timestamp;
    
      const LoadableBlocError(
          {required this.code, required this.error, this.timestamp});
    
      const LoadableBlocError.withCode({required String code, DateTime? timestamp})
          : this(code: code, error: null, timestamp: timestamp);
    
      const LoadableBlocError.withError(
          {required Object error, DateTime? timestamp})
          : this(code: null, error: error, timestamp: timestamp);
    
      @override
      List<Object?> get props => [code, error, timestamp];
    }
    

    This change allows for:

    1. Having a state for loading items action
    2. Having a state for item level actions (delete, fetch, create, update)
      • a) which has the itemId for telling which item was acted on
      • b) success or error state. You can use the error object to show proper message on the presentation layer
      • c) can store the original "request" object just in case needed for logging
      • d) inProgress state - you can show loading spinners on item levels!

    The events look like loadable_bloc_event.dart

    import 'package:equatable/equatable.dart';
    
    class LoadableBlocEvent extends Equatable {
      @override
      List<Object?> get props => [];
    }
    
    class LoadDataEvent extends LoadableBlocEvent {}
    
    class DeleteDataByIdEvent extends LoadableBlocEvent {
      final String itemId;
    
      DeleteDataByIdEvent({required this.itemId});
    
      @override
      List<Object?> get props => [this.itemId];
    }
    
    

    This "mini library" can be used smoothly like in the example below:

    some_widget.dart triggers post deletion

    
    BlocProvider.of<PostsBloc>(context)
                  .add(DeleteDataById(itemId: widget._post.id));
    
    

    posts_bloc.dart

    import 'package:my_app/src/common/common.dart';
    import 'package:my_app/src/features/features.dart';
    import 'package:flutter_bloc/flutter_bloc.dart';
    
    class PostsBloc
        extends Bloc<LoadableBlocEvent, LoadableBlocState<PostsBlocValue>> {
      final PostsService _postsService;
    
      PostsBloc(this._postsService)
          : super(const LoadableBlocState.initial()) {
        on<LoadData>((event, emit) async {
          emit(const LoadableBlocState.loading());
    
          try {
            final r = await _postsService.getPosts();
    
            if (r.statusCode == 200) {
              emit(LoadableBlocState.loaded(PostsBlocValue(
                posts: r.parsedBody!,
                postFolders: r.parsedBody!.folders,
              )));
            } else {
              emit(LoadableBlocState.errorLoading(r));
            }
          } catch (e, stacktrace) {
            recordError(e, stacktrace);
            emit(LoadableBlocState.errorLoading(r));
          }
        });
    
        on<DeleteData<Post>>(
              (event, emit) async {
            final stateData = state.data;
            if (stateData == null) {
              return;
            }
    
            try {
              final r = await _postsService.deletePost(event.item.id);
    
              if (r.statusCode == 200) {
                emit(LoadableBlocState.withAction(
                    action: ItemAction.success(
                        itemId: event.item.id, type: ActionType.delete),
                    data: PostsBlocValue(
                      posts: stateData.posts
                        ..removeWhere((e) => e.id == event.item.id),
                      postFolders: stateData.postFolders,
                    )));
              } else {
                emit(LoadableBlocState.withAction(
                  action: ItemAction.error(
                      itemId: event.item.id,
                      type: ActionType.delete,
                      error: LoadableBlocError.withCode(
                          code: r.statusCode.toString(),
                          timestamp: DateTime.now())),
                  data: state.data,
                ));
              }
            } catch (e, stacktrace) {
              recordError(e, stacktrace);
              emit(LoadableBlocState.withAction(
                action: ItemAction.error(
                    itemId: event.item.id,
                    type: ActionType.delete,
                    error: LoadableBlocError.withError(
                        error: e,
                        timestamp: DateTime.now())),
                data: state.data,
              ));
            }
          },
        );
      }
    }
    
    class PostsBlocValue {
      final List<Post> posts;
    
      PostsBlocValue({required this.posts, required this.postFolders});
    }
    
    

    The PostsService is just using the http package to make some REST HTTP api calls and returns parsed responses.

    Presentation layer usage: post_screen.dart is listening to actions in case of error/success and acts on it

    import 'package:my_app/src/common/common.dart';
    import 'package:my_app/src/features/features.dart';
    import 'package:my_app/src/l10n/l10n.dart';
    import 'package:flutter/material.dart';
    import 'package:flutter_bloc/flutter_bloc.dart';
    
    class PostScreen extends StatefulWidget {
      final Post _post;
    
      const PostScreen(this._post, {super.key});
    
      @override
      State<PostScreen> createState() => _PostScreenState();
    }
    
    class _PostScreenState extends State<PostScreen> {
      UnitsConfig _userUnitsConfig = defaultUnitsConfig;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(
                title: Row(
                  children: [
                    Text(S.of(context).post),
                  ],
                )),
            body: BlocListener<PostsBloc, LoadableBlocState<PostsBlocValue>>(
              listenWhen: (previous, current) =>
              current.action.type != ActionType.fetch &&
                  current.action is ItemAction &&
                  (current.action as ItemAction).itemId == widget._post.id,
              listener: (context, state) {
                final action = state.action;
    
                if (action.type == ActionType.delete) {
                  if (action.error != null) {
                    FlushBarError.error(
                      context,
                      message: S.of(context).internal_server_error,
                      icon: Icons.delete,
                    ).show(context);
                  } else {
                    if (action.didSucceed()) {
                      Navigator.pop(context);
                    }
                  }
                }
              },
              child: (
                  Text(widget._post.name),
              ),
            ));
      }
    }
    
    
    

    Pros

    1. This solution works great and conforms to the ideal bloc architecture pattern.
    2. It also works for parallel actions. Example: Multiple items deletions at once. The events will be emitted in sync and not lost. Listeners will listen and act on them. (I made some tests and works fine in fifo fashion)

    Cons

    1. in my humble opinion I find it over-engineered for such a simple functionality.
    2. It mixes events regarding collection level data loading with item level actions. (that can be ok or not it is subjective opinion). My concern is that there will be many state triggers for every item update. Example: Why should a page that renders the list of posts care about a particular action update? This page is interested only in the data array object. The performance implication is negligible though especially when using listenWhen and we compare the data array.

    Other simpler and elegant "non perfectly clean bloc solutions"

    You keep a simple state class and just introduce a method deletePost in posts_bloc.dart

      Fuuture<Response> deletePost(String id) async {
        final r = await _postsService.deletePost(id);
        if (r.statusCode == 200) {
          emit(LoadableBlocState.loaded(PostsBlocValue(
            posts: r.parsedBody!,
            postFolders: r.parsedBody!.folders,
          )));
        }
        
        return r;
      }
    

    You call that and wait for the answer, then do something in the presentation layer.

    This gives you all the power without the need to dispatch state

    1. You already have the item id of the item that you are acting on (delete/update)
    2. You already have the original request and no need to dispatch it in case of wanting to log etc
    3. You have the response immediately in a "sync" fashion. No need to wait for some event that contains the response or updated data.

    post_screen.dart

    final response = await _postsBloc.deletePost(postId);
    if (response.statusCode !== 200) {
       // show some error scaffold
    }
    

    With the example above you don't need bloclistener or overcomplicated logic to tell what item was updated, when, what action etc and avoid many state emits. But technically it is considered "anti pattern"

    The loadable_bloc_state.dart will be very simple as well as it won't require to keep track of actions.

    import 'package:equatable/equatable.dart';
    
    class LoadableBlocState<T> extends Equatable {
      final bool loading;
      final T? data;
      final Object? error;
    
      const LoadableBlocState._(
          {required this.loading, required this.data, required this.error});
    
      const LoadableBlocState.initial()
          : this._(
              loading: false,
              data: null,
              error: null,
            );
    
      const LoadableBlocState.loading()
          : this._(
              loading: true,
              data: null,
              error: null,
            );
    
      const LoadableBlocState.loaded(T data)
          : this._(
              loading: false,
              data: data,
              error: null,
            );
    
      const LoadableBlocState.error(Object error)
          : this._(
              loading: false,
              data: null,
              error: error,
            );
    
      @override
      List<Object?> get props => [this.loading, this.data, this.error];
    }