How I handle errors in Flutter

ยท

5 min read

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 :)

ย