Comprehensive Flutter Error Handling: Best Practices and Patterns
•12 min read
Error handling is a crucial aspect of building robust Flutter applications. This guide covers comprehensive error handling strategies, patterns, and best practices to make your Flutter apps more reliable and user-friendly.
1. Basic Error Handling
Try-Catch Blocks
Future<void> fetchData() async { try { final response = await http.get(Uri.parse('https://api.example.com/data')); if (response.statusCode == 200) { // Handle successful response } else { throw HttpException('Failed to load data'); } } on SocketException catch (e) { // Handle network errors showErrorDialog('No internet connection'); } on HttpException catch (e) { // Handle HTTP errors showErrorDialog(e.message); } on FormatException catch (e) { // Handle JSON parsing errors showErrorDialog('Invalid response format'); } catch (e) { // Handle other errors showErrorDialog('An unexpected error occurred'); } }
Custom Error Classes
class AppException implements Exception { final String message; final String? code; final dynamic details; AppException(this.message, {this.code, this.details}); @override String toString() => 'AppException: $message (Code: $code)'; } class NetworkException extends AppException { NetworkException([String? message]) : super(message ?? 'A network error occurred'); } class ValidationException extends AppException { ValidationException(String message, {String? field}) : super(message, details: {'field': field}); }
2. Error Handling in State Management
Using BLoC Pattern
abstract class DataState {} class DataInitial extends DataState {} class DataLoading extends DataState {} class DataLoaded extends DataState { final List<dynamic> data; DataLoaded(this.data); } class DataError extends DataState { final String message; DataError(this.message); } class DataBloc extends Cubit<DataState> { DataBloc() : super(DataInitial()); Future<void> fetchData() async { emit(DataLoading()); try { final result = await apiService.getData(); emit(DataLoaded(result)); } on NetworkException catch (e) { emit(DataError('Network error: ${e.message}')); } catch (e) { emit(DataError('An unexpected error occurred')); } } }
Using Provider Pattern
class DataProvider with ChangeNotifier { List<dynamic>? _data; String? _error; bool _loading = false; bool get isLoading => _loading; List<dynamic>? get data => _data; String? get error => _error; Future<void> fetchData() async { try { _loading = true; _error = null; notifyListeners(); _data = await apiService.getData(); _loading = false; notifyListeners(); } catch (e) { _loading = false; _error = e.toString(); notifyListeners(); } } }
3. Network Error Handling
HTTP Error Handling
class ApiService { final dio = Dio(); Future<T> handleResponse<T>(Response response, T Function(dynamic) onSuccess) async { switch (response.statusCode) { case 200: return onSuccess(response.data); case 400: throw ValidationException('Invalid request'); case 401: throw AuthException('Unauthorized'); case 404: throw NotFoundException('Resource not found'); case 500: throw ServerException('Internal server error'); default: throw NetworkException('Network error: ${response.statusCode}'); } } Future<T> safeApiCall<T>(Future<T> Function() apiCall) async { try { return await apiCall(); } on DioError catch (e) { switch (e.type) { case DioErrorType.connectTimeout: case DioErrorType.receiveTimeout: throw NetworkException('Connection timeout'); case DioErrorType.response: throw handleDioError(e.response); default: throw NetworkException('Network error occurred'); } } catch (e) { throw AppException('An unexpected error occurred'); } } }
Retry Mechanism
class RetryableRequest { final Future<dynamic> Function() request; final int maxAttempts; final Duration delay; RetryableRequest({ required this.request, this.maxAttempts = 3, this.delay = const Duration(seconds: 1), }); Future<T> execute<T>() async { int attempts = 0; while (attempts < maxAttempts) { try { return await request() as T; } catch (e) { attempts++; if (attempts == maxAttempts) rethrow; await Future.delayed(delay * attempts); } } throw Exception('Max retry attempts reached'); } } // Usage final result = await RetryableRequest( request: () => apiService.getData(), maxAttempts: 3, delay: Duration(seconds: 2), ).execute();
4. UI Error Handling
Error Boundary Widget
class ErrorBoundary extends StatefulWidget { final Widget child; final Widget Function(FlutterErrorDetails) onError; ErrorBoundary({ required this.child, required this.onError, }); @override _ErrorBoundaryState createState() => _ErrorBoundaryState(); } class _ErrorBoundaryState extends State<ErrorBoundary> { FlutterErrorDetails? _error; @override void initState() { super.initState(); FlutterError.onError = (FlutterErrorDetails details) { setState(() { _error = details; }); }; } @override Widget build(BuildContext context) { if (_error != null) { return widget.onError(_error!); } return widget.child; } } // Usage ErrorBoundary( onError: (error) => ErrorView( message: 'Something went wrong', onRetry: () => setState(() => _error = null), ), child: YourWidget(), )
Error Widget
class ErrorView extends StatelessWidget { final String message; final VoidCallback? onRetry; const ErrorView({ required this.message, this.onRetry, }); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 48, color: Colors.red), SizedBox(height: 16), Text( message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), if (onRetry != null) ...[ SizedBox(height: 16), ElevatedButton( onPressed: onRetry, child: Text('Retry'), ), ], ], ), ); } }
5. Form Validation
Form Validation Handler
class FormValidator { static String? validateEmail(String? value) { if (value == null || value.isEmpty) { return 'Email is required'; } final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); if (!emailRegex.hasMatch(value)) { return 'Invalid email format'; } return null; } static String? validatePassword(String? value) { if (value == null || value.isEmpty) { return 'Password is required'; } if (value.length < 8) { return 'Password must be at least 8 characters'; } return null; } } // Usage in Form Form( key: _formKey, child: Column( children: [ TextFormField( validator: FormValidator.validateEmail, decoration: InputDecoration( labelText: 'Email', errorStyle: TextStyle(color: Colors.red), ), ), TextFormField( validator: FormValidator.validatePassword, obscureText: true, decoration: InputDecoration( labelText: 'Password', errorStyle: TextStyle(color: Colors.red), ), ), ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { // Form is valid, proceed with submission } }, child: Text('Submit'), ), ], ), )
6. Global Error Handling
Error Handler Service
class ErrorHandler { static final ErrorHandler _instance = ErrorHandler._internal(); factory ErrorHandler() => _instance; ErrorHandler._internal(); void initialize() { FlutterError.onError = (FlutterErrorDetails details) { FlutterError.presentError(details); _reportError(details.exception, details.stack); }; PlatformDispatcher.instance.onError = (error, stack) { _reportError(error, stack); return true; }; } Future<void> _reportError(dynamic error, StackTrace? stack) async { // Log error to analytics service await FirebaseCrashlytics.instance.recordError(error, stack); // Log to console in debug mode if (kDebugMode) { print('Error: $error'); print('Stack trace: $stack'); } } void showErrorDialog(BuildContext context, String message) { showDialog( context: context, builder: (context) => AlertDialog( title: Text('Error'), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('OK'), ), ], ), ); } }
Best Practices
- Use Specific Error Types
// Bad throw Exception('Something went wrong'); // Good throw NetworkException('Failed to connect to server');
- Graceful Error Recovery
Future<void> loadData() async { try { final data = await fetchData(); updateUI(data); } catch (e) { // Show error UI but keep app running showErrorView(); // Log error for debugging logError(e); } }
- Error Boundaries for Widget Trees
MaterialApp( builder: (context, child) { return ErrorBoundary( onError: (error) => ErrorScreen(), child: child!, ); }, home: HomePage(), )
- Consistent Error Handling
// Create a centralized error handler class AppErrorHandler { static void handleError(BuildContext context, dynamic error) { String message; if (error is NetworkException) { message = 'Network error occurred'; } else if (error is ValidationException) { message = error.message; } else { message = 'An unexpected error occurred'; } showErrorDialog(context, message); } }
Conclusion
Effective error handling is crucial for building robust Flutter applications. Key takeaways:
- Use specific error types for different scenarios
- Implement proper error boundaries
- Handle network errors gracefully
- Validate form inputs thoroughly
- Implement global error handling
- Provide meaningful error messages to users
- Log errors for debugging and analytics
- Implement retry mechanisms where appropriate
- Keep the app functional even when errors occur
- Follow consistent error handling patterns
Remember:
- Always provide user-friendly error messages
- Log errors for debugging purposes
- Implement proper error recovery mechanisms
- Use error boundaries to prevent app crashes
- Handle both expected and unexpected errors
- Test error scenarios thoroughly
By following these practices, you can create more reliable and user-friendly Flutter applications that gracefully handle errors and provide better user experience.