Flutter Error Handling and Logging Best Practices
Building robust Flutter applications means handling errors gracefully and logging information that helps you debug issues in production. Whether you're a beginner learning the ropes or an intermediate developer looking to improve your app's reliability, understanding error handling and logging patterns is essential.
In this article, we'll explore practical strategies for managing errors in Flutter, setting up effective logging, and ensuring your app provides a smooth experience even when things go wrong.
Understanding Flutter's Error Types
Flutter distinguishes between different types of errors, and understanding these distinctions helps you handle them appropriately:
- Synchronous errors: Occur during normal code execution (like null pointer exceptions or type errors)
- Asynchronous errors: Happen in Futures, Streams, or async operations
- Flutter framework errors: Widget build failures or rendering issues
Each type requires a different approach. Let's start with synchronous error handling:
class DataService {
Future<User> fetchUser(String userId) async {
try {
final response = await http.get(
Uri.parse('https://api.example.com/users/$userId')
);
if (response.statusCode == 200) {
return User.fromJson(jsonDecode(response.body));
} else {
throw ApiException('Failed to fetch user: ${response.statusCode}');
}
} on SocketException {
throw NetworkException('No internet connection');
} on FormatException {
throw DataException('Invalid data format');
} catch (e) {
throw UnknownException('Unexpected error: $e');
}
}
}
Creating Custom Exception Classes
Instead of throwing generic exceptions, create specific exception classes that provide context and make error handling more precise:
abstract class AppException implements Exception {
final String message;
final String? code;
AppException(this.message, {this.code});
@override
String toString() => message;
}
class NetworkException extends AppException {
NetworkException(String message) : super(message, code: 'NETWORK_ERROR');
}
class ApiException extends AppException {
final int? statusCode;
ApiException(String message, {this.statusCode})
: super(message, code: 'API_ERROR');
}
class DataException extends AppException {
DataException(String message) : super(message, code: 'DATA_ERROR');
}
class UnknownException extends AppException {
UnknownException(String message) : super(message, code: 'UNKNOWN_ERROR');
}
Custom exceptions make it easier to handle different error scenarios in your UI and provide better user feedback.
Setting Up Structured Logging
Effective logging is crucial for debugging production issues. Instead of using print statements scattered throughout your code, set up a proper logging system. The diagram below shows how logging flows through your application:
Here's a simple but effective logger implementation:
enum LogLevel { debug, info, warning, error }
class AppLogger {
static const String _tag = 'AppLogger';
static void log(String className, String methodName, String message, {
LogLevel level = LogLevel.info,
Object? error,
StackTrace? stackTrace,
}) {
final timestamp = DateTime.now().toIso8601String();
final logMessage = '[$timestamp] [$level] $className.$methodName: $message';
if (error != null) {
debugPrint('$logMessage\nError: $error');
if (stackTrace != null) {
debugPrint('StackTrace: $stackTrace');
}
} else {
debugPrint(logMessage);
}
// In production, you might want to send logs to a service
// like Firebase Crashlytics, Sentry, or your own backend
_sendToLoggingService(level, logMessage, error, stackTrace);
}
static void _sendToLoggingService(
LogLevel level,
String message,
Object? error,
StackTrace? stackTrace,
) {
// Only send errors and warnings in production
if (level == LogLevel.error || level == LogLevel.warning) {
// Integrate with your logging service here
// FirebaseCrashlytics.instance.recordError(error, stackTrace);
}
}
static void debug(String className, String methodName, String message) {
log(className, methodName, message, level: LogLevel.debug);
}
static void info(String className, String methodName, String message) {
log(className, methodName, message, level: LogLevel.info);
}
static void warning(String className, String methodName, String message, {
Object? error,
}) {
log(className, methodName, message, level: LogLevel.warning, error: error);
}
static void error(String className, String methodName, String message, {
Object? error,
StackTrace? stackTrace,
}) {
log(className, methodName, message,
level: LogLevel.error,
error: error,
stackTrace: stackTrace,
);
}
}
Now you can use this logger throughout your app with consistent formatting:
class UserRepository {
final DataService _dataService;
UserRepository(this._dataService);
Future<User> getUser(String userId) async {
AppLogger.info('UserRepository', 'getUser', 'Fetching user: $userId');
try {
final user = await _dataService.fetchUser(userId);
AppLogger.info('UserRepository', 'getUser', 'User fetched successfully');
return user;
} on NetworkException catch (e) {
AppLogger.error('UserRepository', 'getUser',
'Network error while fetching user',
error: e,
stackTrace: StackTrace.current,
);
rethrow;
} catch (e, stackTrace) {
AppLogger.error('UserRepository', 'getUser',
'Unexpected error fetching user',
error: e,
stackTrace: stackTrace,
);
throw UnknownException('Failed to fetch user');
}
}
}
Handling Errors in the UI Layer
Your UI should gracefully handle errors and provide meaningful feedback to users. The Result type pattern makes error handling explicit and type-safe. Here's how it works:
Here's a pattern using a Result type that makes error handling explicit:
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
}
class Failure<T> extends Result<T> {
final AppException error;
const Failure(this.error);
}
Now let's see how to use this in a widget:
class UserProfileWidget extends StatefulWidget {
final String userId;
const UserProfileWidget({required this.userId, super.key});
@override
State<UserProfileWidget> createState() => _UserProfileWidgetState();
}
class _UserProfileWidgetState extends State<UserProfileWidget> {
Result<User>? _result;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadUser();
}
Future<void> _loadUser() async {
setState(() {
_isLoading = true;
_result = null;
});
try {
final user = await UserRepository(DataService()).getUser(widget.userId);
setState(() {
_result = Success(user);
_isLoading = false;
});
} on NetworkException {
setState(() {
_result = Failure(NetworkException('Please check your internet connection'));
_isLoading = false;
});
} on ApiException catch (e) {
setState(() {
_result = Failure(ApiException('Unable to load user profile'));
_isLoading = false;
});
} catch (e) {
setState(() {
_result = Failure(UnknownException('Something went wrong'));
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return _result?.when(
success: (user) => UserDetailsView(user: user),
failure: (error) => ErrorView(
error: error,
onRetry: _loadUser,
),
) ?? const SizedBox.shrink();
}
}
extension ResultExtension<T> on Result<T> {
R when<R>({
required R Function(T data) success,
required R Function(AppException error) failure,
}) {
return switch (this) {
Success(data: final data) => success(data),
Failure(error: final error) => failure(error),
};
}
}
Global Error Handling
For unhandled errors, Flutter provides several mechanisms to catch them globally. The diagram below illustrates the error handling layers:
Set up error handlers in your main function:
void main() {
// Handle Flutter framework errors
FlutterError.onError = (FlutterErrorDetails details) {
AppLogger.error('FlutterError', 'onError',
'Flutter framework error: ${details.exception}',
error: details.exception,
stackTrace: details.stack,
);
// In production, report to crash reporting service
// FirebaseCrashlytics.instance.recordFlutterError(details);
};
// Handle asynchronous errors outside Flutter framework
PlatformDispatcher.instance.onError = (error, stack) {
AppLogger.error('PlatformDispatcher', 'onError',
'Unhandled async error: $error',
error: error,
stackTrace: stack,
);
return true; // Return true to prevent default error handling
};
runApp(const MyApp());
}
Error Boundaries for Widget Trees
Sometimes you want to isolate errors to specific parts of your widget tree. You can create an error boundary widget that catches errors during build:
class ErrorBoundary extends StatefulWidget {
final Widget child;
final Widget Function(BuildContext context, Object error)? fallback;
const ErrorBoundary({
required this.child,
this.fallback,
super.key,
});
@override
State<ErrorBoundary> createState() => _ErrorBoundaryState();
}
class _ErrorBoundaryState extends State<ErrorBoundary> {
Object? _error;
@override
void didCatchError(Object error, StackTrace stackTrace) {
AppLogger.error('ErrorBoundary', 'didCatchError',
'Widget tree error caught',
error: error,
stackTrace: stackTrace,
);
setState(() {
_error = error;
});
}
@override
Widget build(BuildContext context) {
if (_error != null) {
return widget.fallback?.call(context, _error!) ??
ErrorView(
error: UnknownException('Widget rendering failed'),
onRetry: () {
setState(() {
_error = null;
});
},
);
}
return widget.child;
}
}
Best Practices Summary
Here are key takeaways for implementing error handling and logging in your Flutter apps:
- Use specific exception types: Create custom exception classes instead of throwing generic exceptions. This makes error handling more precise and maintainable.
- Log consistently: Use a structured logging system with consistent formatting. Always include class name, method name, and meaningful messages.
- Handle errors at the right level: Catch and handle errors as close to their source as possible, but provide user-friendly messages at the UI layer.
- Don't swallow errors silently: Always log errors, even if you're handling them gracefully. This helps with debugging production issues.
- Provide retry mechanisms: For transient errors like network failures, give users the option to retry the operation.
- Set up global error handlers: Catch unhandled errors at the app level to prevent crashes and gather diagnostic information.
- Use Result types: Make error handling explicit in your code by using Result types instead of throwing exceptions everywhere.
Putting It All Together
Here's a complete example showing how these patterns work together:
class ApiService {
final http.Client _client;
ApiService(this._client);
Future<Result<Map<String, dynamic>>> getData(String endpoint) async {
AppLogger.info('ApiService', 'getData', 'Requesting: $endpoint');
try {
final response = await _client.get(Uri.parse(endpoint));
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
AppLogger.info('ApiService', 'getData', 'Request successful');
return Success(data);
} else {
final error = ApiException(
'Request failed with status ${response.statusCode}',
statusCode: response.statusCode,
);
AppLogger.warning('ApiService', 'getData',
'Request failed',
error: error,
);
return Failure(error);
}
} on SocketException catch (e) {
final error = NetworkException('No internet connection');
AppLogger.error('ApiService', 'getData',
'Network error',
error: e,
stackTrace: StackTrace.current,
);
return Failure(error);
} catch (e, stackTrace) {
final error = UnknownException('Unexpected error: $e');
AppLogger.error('ApiService', 'getData',
'Unexpected error',
error: e,
stackTrace: stackTrace,
);
return Failure(error);
}
}
}
By following these patterns, you'll build Flutter applications that handle errors gracefully, provide excellent user experiences, and give you the diagnostic information you need to fix issues quickly. Remember, good error handling isn't just about preventing crashes—it's about creating resilient applications that users can rely on.