skip to Main Content

I have one bloc, example PostsBloc that exposes one event LoadData and one event DeleteData and emits state of type LoadableBlocState<Post> (class code below)

This bloc is using a repository which calls http endpoints to achieve this.

If the delete call gets a response statusCode !== 200 I want to show error message to the frontend (scaffold). I want to show this "every time this error happens"

If the deletion is successful (statusCode 200) I want to remove this post from the data and also show success message in the ui.
If I delete the post from the post screen itself (example post_screen.dart having loaded post id 3, then want to go back pop context.

I tried for the delete error

Having a state which can hold "non fatal" errors and listening to them

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,
}

I listened to blocs emiting states of the kind above using

  1. Bloclistener and did not work because it triggers only 1 time and not a 2 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. BlocConsumer, worked similar to above

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.

2

Answers


  1. Chosen as BEST ANSWER

    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];
    }
    
    

  2. As you said your problem is for the second TapOn the error message would not shown, the solution is to change state to the loading or anything else right after user clicked on the button and then start delete process, the result be and error or success

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search