Flutter | Manage API Errors Globally with Custom ErrorDialogManager

User experience is crucial in mobile applications. One of the key factors that directly impact the app experience is how errors are handled. There is no such thing as error-free code — errors will always occur. Even if they don’t originate from the client, they may come from the server. In such cases, our primary goal is to provide the user with the right feedback.
In this article, you will learn how to manage network errors from your service in a centralized way within your Flutter applications. Let’s get started.
First, let’s create our dialog manager class, which will handle error display.
class InterceptorErrorDialogManager {
const InterceptorErrorDialogManager._();
static final StreamController<AppException> _errorStreamController =
StreamController<AppException>.broadcast();
static Stream<AppException> get errorStream => _errorStreamController.stream;
static void addError(AppException exception) {
_errorStreamController.add(exception);
}
static void show({
required ErrorDialogData data,
BuildContext? context,
VoidCallback? onDismiss,
}) {
showDialog<void>(
context: context ??
routerConfig.configuration.navigatorKey.currentState!.context,
builder: (context) => AlertDialog(
title: Text(data.title),
actionsAlignment: MainAxisAlignment.center,
content: Text(data.body),
icon: const Icon(Icons.error),
actions: [
ElevatedButton(
onPressed: data.onAction,
child: Text(data.actionTitle ?? 'Ok'),
),
],
),
).then((_) {
onDismiss?.call();
});
}
}
class ErrorDialogData {
ErrorDialogData({
required this.title,
required this.body,
this.onAction,
this.actionTitle,
});
final String title;
final String body;
final String? actionTitle;
final FutureOr<void> Function()? onAction;
}
In this class, an important point to note is that the stream structure inside it accepts an AppException
base class. You can define this class as follows.
abstract class AppException implements Exception {}
Instead of using the AppException
class, you could directly use the Exception
class. However, in our projects, we usually avoid using a class, data type, or package exactly as it is. Adding an extra layer can be beneficial for future business logic requirements. Let this exception be specific to our app and stay there for future use.
Now, let’s define our interceptor class. This class will handle errors specific to the responses from our APIs. For example, we can create a class to handle errors coming from our authentication service like this:
class AuthServiceInterceptor extends InterceptorsWrapper {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 401) {
InterceptorErrorDialogManager.addError(
SpecialException(code: 401, path: 'https://example.com'),
);
}
super.onError(err, handler);
}
}
From this code, we can see that the SpecialException
class is derived from our AppException
class. However, checking only the status code of the incoming error might not be sufficient. After all, Dio does not return a 401
status code for just a single request.
Since this is written for testing purposes, we assume we already know the error code, which is why we handle it this way. In a real project, you might also want to check err.requestOptions.path
to verify whether the error occurred for the specific request path you were expecting.
Don’t forget to add
AuthServiceInterceptor
as an interceptor when creating the Dio instance.
Alright, we added our error to the stream using addError
, but how do we listen to it across the entire application? There's a perfect place for this—right on MaterialApp
.
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
debugShowCheckedModeBanner: false,
routerConfig: routerConfig,
builder: (context, child) {
AppInitializer.handleAppErrors(); // Here is good. :)
return child!;
},
);
}
}
class AppInitializers{
static void handleAppErrors() {
InterceptorErrorDialogManager.errorStream.listen((error) {
if(error is SpecialException){
// showDialog
}
});
}
}
You can add any exceptions you want inside the if
block and display them accordingly. If this section gets too cluttered, you can move the logic to a separate ErrorManager
class. But for now, this should work fine.
We used the InterceptorErrorDialogManager
class to handle errors coming from Dio interceptors, but don't think it's limited to just that. You can call show()
anywhere in your application to display a dialog.
I hope this was helpful — or at least gave you some new ideas. Thanks for reading! Feel free to ask me any questions. Have a great Sunday 🎈.