Back to Posts

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

  1. Use Specific Error Types
// Bad
throw Exception('Something went wrong');

// Good
throw NetworkException('Failed to connect to server');
  1. 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);
  }
}
  1. Error Boundaries for Widget Trees
MaterialApp(
  builder: (context, child) {
    return ErrorBoundary(
      onError: (error) => ErrorScreen(),
      child: child!,
    );
  },
  home: HomePage(),
)
  1. 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:

  1. Use specific error types for different scenarios
  2. Implement proper error boundaries
  3. Handle network errors gracefully
  4. Validate form inputs thoroughly
  5. Implement global error handling
  6. Provide meaningful error messages to users
  7. Log errors for debugging and analytics
  8. Implement retry mechanisms where appropriate
  9. Keep the app functional even when errors occur
  10. 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.