I've seen many different approaches on handling errors in Flutter projects, some even involves functional programming paradigm... Here I will show my take on how I handle errors while taking care of internationalisation (nope I'm not using easy_localization
, I'm using the official way, with BuildContext
). In this article I may be using the term error and exception interchangeably, though there are some distinct differences between them, so just be aware.
My tech stack in a Flutter project usually involves bloc
, but I think this approach can also apply to other state management approaches. Here is how it looks at the presentation layer:
class CategoriesPageGridView extends StatelessWidget {
const CategoriesPageGridView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CategoriesCubit, CategoriesState>(
builder: (context, state) => switch (state) {
CategoriesInitial() ||
CategoriesLoading() =>
CategoriesPageGrid.loading(context),
CategoriesError() => Center(
child: Text(state.getMessage(context)),
),
CategoriesLoaded() => CategoriesPageGrid(
items: state.currentData!.map((category) {
return CategoryGridItem(
category: category,
onTap: () {},
);
}).toList(),
),
},
);
}
}
The state.getMessage(context)
function would return a String
that contains the localised error message (e.g. context.l10n.networkError
), and it would be rendered when the error state CategoriesError
is emitted by the CategoriesCubit
. Let's have a look how our CategoriesCubit
and CategoriesState
look:
class CategoriesCubit extends Cubit<CategoriesState> {
CategoriesCubit({
required BooksRepository booksRepository,
}) : _booksRepository = booksRepository,
super(const CategoriesInitial(null));
final BooksRepository _booksRepository;
void fetchCategories() async {
try {
emit(CategoriesLoading(state.currentData));
final categories = await _booksRepository.fetchCategories();
emit(CategoriesLoaded(categories));
} on AppError catch (error) {
emit(
CategoriesError(
getMessage: error.getMessage,
currentData: state.currentData,
),
);
}
}
}
}
sealed class CategoriesState extends Equatable {
const CategoriesState(this.currentData);
final Iterable<Category>? currentData;
@override
List<Object?> get props => [currentData];
}
final class CategoriesInitial extends CategoriesState {
const CategoriesInitial(super.currentData);
}
final class CategoriesLoading extends CategoriesState {
const CategoriesLoading(super.currentData);
}
final class CategoriesLoaded extends CategoriesState {
const CategoriesLoaded(super.currentData);
}
final class CategoriesError extends CategoriesState {
const CategoriesError({
required this.getMessage,
required Iterable<Category>? currentData,
}) : super(currentData);
final String Function(BuildContext) getMessage;
@override
List<Object?> get props => [getMessage, currentData];
}
Let's put the focus on the CategoriesError
for now. It contains a getMessage
function that returns a String
with a provided BuildContext
. From the fetchCategories()
function in the CategoriesCubit
, you can see that it is passing the error.getMessage
function from AppError
to the CategoriesError
state. But what is this AppError
and where does it come from?
Before I get into what is AppError
, let's see who is throwing it and how was it thrown. In the fetchCategories()
function, the culprit who can throw the AppError
is await _booksRepository.fetchCategories()
, so let's have a look at the repository:
class BooksRepository {
BooksRepository({
required BackendApiClient apiClient,
}) : _apiClient = apiClient;
final BackendApiClient _apiClient;
BooksApi get _api => _apiClient.http.getBooksApi();
Future<Iterable<Category>> fetchCategories() async {
try {
final response = await _api.getCategories();
return response.data!;
} catch (error, stackTrace) {
AppError.throwWithStackTrace(FetchCategoriesException(error), stackTrace);
}
}
}
As you can see, an AppError
is being thrown at a repository method whenever an exception is caught. Ideally, I will do the same for every repository method so that I can transform those exceptions into AppError
before it was caught by the callers (in our case, it's usually the blocs/cubits).
And if you're wondering, here is the definition of FetchCategoriesException
:
class FetchCategoriesException extends CustomException {
const FetchCategoriesException(super.error);
@override
String getMessage(BuildContext context) {
return context.l10n.failedToLoadCategoriesErrorMessage;
}
}
Wait... what is this CustomException
?? Let me explain together with the AppError
class:
class AppError {
AppError({
required this.error,
required this.getMessage,
});
final Object error;
final String Function(BuildContext) getMessage;
static String Function(BuildContext)? _getClientErrorMessage(Object error) {
switch (error) {
case HandshakeException:
case HttpException:
case SocketException:
return (context) => context.l10n.networkErrorMessage;
case final DioException e:
switch (e.error) {
case Object _ when e.type == DioExceptionType.connectionTimeout:
case Object _ when e.type == DioExceptionType.connectionError:
case SocketException:
return (context) => context.l10n.networkErrorMessage;
}
switch (e.response) {
case Object _ when e.response?.statusCode == 401:
final isTokenSentInRequest =
e.requestOptions.headers[HttpHeaders.authorizationHeader] !=
null;
if (isTokenSentInRequest) {
throw SessionExpiredError(e);
}
throw UnauthorizedError(e);
case Object _ when e.response?.statusCode == 404:
return (context) => context.l10n.serverUnavailableErrorMessage;
// additional cases here if you want to parse server responses
// e.g. server validation errors
}
default:
return null; // leave it to CustomException to fill this up
}
}
static Never throwWithStackTrace(Object error, StackTrace stackTrace) {
final getMessage =
_getClientErrorMessage(error is CustomException ? error.error : error);
Error.throwWithStackTrace(
AppError(
error: error is CustomException ? error.error : error,
getMessage: getMessage ?? (context) => context.l10n.defaultErrorMessage,
),
stackTrace,
);
}
}
It looks quite lengthy, because I was trying to map the exceptions thrown by both http
and dio
package to a user-friendly error message, plus wrapping the default Error.throwWithStackTrace(...)
with our own so the catcher would receive the getMessage
function that then allows our error messages to be displayed in translated languages.
Now there are still two missing pieces - what is CustomException
and why were SessionExpiredError
and UnauthorizedError
thrown?
abstract class CustomException implements Exception {
const CustomException(this.error);
/// The error which was caught.
final Object error;
/// User-friendly message to show.
String getMessage(BuildContext context);
}
class UnauthorizedError extends CustomException {
const UnauthorizedError(Object error) : super(error);
@override
String getMessage(BuildContext context) {
return context.l10n.unauthorizedErrorMessage;
}
}
class SessionExpiredError extends CustomException {
const SessionExpiredError(Object error) : super(error);
@override
String getMessage(BuildContext context) {
return context.l10n.sessionExpiredErrorMessage;
}
}
Turned out CustomException
is just a base class I created, that implements the Exception
class, so that it contains a localised message getter getMessage(BuildContext context)
. The UnauthorizedError
and SessionExpiredError
were created the same way as the FetchCategoriesException
.
These two errors were intentionally thrown by AppError
because I intend to handle them at the global level:
PlatformDispatcher.instance.onError = (error, stack) {
if (error is UnauthorizedError) {
appBloc.add(const UnauthorizedEvent());
} else if (error is SessionExpiredError) {
appBloc.add(const SessionExpiredEvent());
} else {
log('๐ฅ $error', stackTrace: stack);
// or call Sentry for error reporting
}
return true;
};
Tada! I let the PlatformDispatcher
catch the two uncaught errors so that I can add the corresponding event to my top-level AppBloc
, which is responsible for handling top-level events in the whole app. I can then use a BlocListener
to show a snackbar, dialog, and/or perform navigations. The possibilities are endless.
So far this is working pretty well for me... What do you think? Share your thoughts and maybe your approach with me in the comments :)