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
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
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.
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:
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
Cons
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
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];
}