Streamlining Asynchronous Operations in Flutter with Error Handling and Retry Mechanism
Introduction Handling asynchronous operations in Flutter can be challenging, especially when dealing with API calls, error handling, retries, loading states, and user-friendly feedback mechanisms. In this post, we’ll explore a reusable framework that simplifies async operations by handling these challenges while keeping your codebase clean and maintainable. The Problem When working with API calls and other asynchronous tasks, Flutter developers frequently face: Repetitive try-catch blocks scattered throughout the codebase Inconsistent error handling approaches Manual management of loading indicators Allowing users to retry failed operations A centralized approach can help standardize how we execute async operations, making the codebase cleaner and more maintainable. The Solution: OperationRunnerState The OperationRunnerState class provides a reusable way to execute operations while handling errors and loading states. Key Features Encapsulates operation execution: Abstracts away repetitive error handling and loading UI logic. Error Handling: Catches API (DioException) and generic errors, displaying appropriate messages. Intuitive Retry Mechanism: Allow users to retry failed operations with a single tap Clean Implementation: Reduce boilerplate with a simple, reusable pattern Implementation Details The Operation Type typedef Operation = Future Function(); This simple typedef defines Operation as a function that returns a Future, allowing flexibility to execute any async task. Executing an Operation abstract class OperationRunnerState extends State { @protected Future runOperation( Operation operation, { BuildContext? contextE, bool showLoader = true, }) async { contextE ??= context; Future retryOperation() async { await runOperation( operation, contextE: contextE, showLoader: showLoader, ); } try { if (showLoader) showLoading(context, loadingText: loadingText); final result = await operation(); if (showLoader) Navigator.pop(context); return result as T; } on DioException catch (e) { _handleException(context, e.message, showLoader, retryOperation); } catch (e) { await _handleErrors( context, e.toString(), retryOperation, ); return null; } return null; } } Error Handling void _handleException( BuildContext context, String? message, bool showLoader, Future Function()? retryCallback ) async { if (showLoader) Navigator.pop(context); _handleErrors(context, message ?? "An error occurred", retryCallback); } Future _handleErrors( BuildContext context, String message, Future Function()? retryCallback ) async { final shouldRetry = await context.pushNamed(errorScreenRoute, extra: { "errorMessage": message, "onRetry": retryCallback, }); if (shouldRetry && retryCallback != null) { await retryCallback(); } return shouldRetry; } Showing a Loading Indicator void showLoading(BuildContext context, {String? loadingText}) { showDialog( context: context, barrierDismissible: false, builder: (context) { return LoadingIndicator(loadingText: loadingText); }, ); } Usage Example Here's how you can use OperationRunnerState in your widget: class ProductListScreen extends StatefulWidget { @override _ProductListScreenState createState() => _ProductListScreenState(); } class _ProductListScreenState extends OperationRunnerState { List products = []; @override void initState() { super.initState(); _fetchProducts(); } Future _fetchProducts() async { final result = await runOperation( () => apiService.getProducts(), ); if (result != null) { setState(() => products = result); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Products')), body: ListView.builder( itemCount: products.length, itemBuilder: (context, index) => ProductTile(products[index]), ), ); } } Benefits DRY Principle: Write error handling logic once, use it everywhere Consistent UX: Provide uniform feedback across your entire application and allows retrying failed operations Enhanced Maintainability: Centralize complex async operation management Developer Productivity: Focus on business logic instead of error handling Improved Testability: Separate concerns for easier testing Conclusion The OperationRunnerState pattern isn't just a coding technique, it's a strategic approach to building more robust Flutter applications.Integrating it into your flutter app means you can simplify asynchronous operations while ensuring robust error handling and a better user experience. If you’re working on a Flutter app that relies heavily on API calls or other async tasks, adopting this framework wil

Introduction
Handling asynchronous operations in Flutter can be challenging, especially when dealing with API calls, error handling, retries, loading states, and user-friendly feedback mechanisms.
In this post, we’ll explore a reusable framework that simplifies async operations by handling these challenges while keeping your codebase clean and maintainable.
The Problem
When working with API calls and other asynchronous tasks, Flutter developers frequently face:
- Repetitive try-catch blocks scattered throughout the codebase
- Inconsistent error handling approaches
- Manual management of loading indicators
- Allowing users to retry failed operations
A centralized approach can help standardize how we execute async operations, making the codebase cleaner and more maintainable.
The Solution: OperationRunnerState
The OperationRunnerState class provides a reusable way to execute operations while handling errors and loading states.
Key Features
- Encapsulates operation execution: Abstracts away repetitive error handling and loading UI logic.
- Error Handling: Catches API (DioException) and generic errors, displaying appropriate messages.
- Intuitive Retry Mechanism: Allow users to retry failed operations with a single tap
- Clean Implementation: Reduce boilerplate with a simple, reusable pattern
Implementation Details
The Operation Type
typedef Operation
This simple typedef defines Operation
as a function that returns a Future
, allowing flexibility to execute any async task.
Executing an Operation
abstract class OperationRunnerState extends State {
@protected
Future runOperation(
Operation operation, {
BuildContext? contextE,
bool showLoader = true,
}) async {
contextE ??= context;
Future retryOperation() async {
await runOperation(
operation,
contextE: contextE,
showLoader: showLoader,
);
}
try {
if (showLoader) showLoading(context, loadingText: loadingText);
final result = await operation();
if (showLoader) Navigator.pop(context);
return result as T;
} on DioException catch (e) {
_handleException(context, e.message, showLoader, retryOperation);
} catch (e) {
await _handleErrors(
context,
e.toString(),
retryOperation,
);
return null;
}
return null;
}
}
Error Handling
void _handleException(
BuildContext context,
String? message,
bool showLoader,
Future Function()? retryCallback
) async {
if (showLoader) Navigator.pop(context);
_handleErrors(context, message ?? "An error occurred", retryCallback);
}
Future
Showing a Loading Indicator
void showLoading(BuildContext context, {String? loadingText}) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) {
return LoadingIndicator(loadingText: loadingText);
},
);
}
Usage Example
Here's how you can use OperationRunnerState in your widget:
class ProductListScreen extends StatefulWidget {
@override
_ProductListScreenState createState() => _ProductListScreenState();
}
class _ProductListScreenState extends OperationRunnerState {
List products = [];
@override
void initState() {
super.initState();
_fetchProducts();
}
Future _fetchProducts() async {
final result = await runOperation>(
() => apiService.getProducts(),
);
if (result != null) {
setState(() => products = result);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Products')),
body: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductTile(products[index]),
),
);
}
}
Benefits
- DRY Principle: Write error handling logic once, use it everywhere
- Consistent UX: Provide uniform feedback across your entire application and allows retrying failed operations
- Enhanced Maintainability: Centralize complex async operation management
- Developer Productivity: Focus on business logic instead of error handling
- Improved Testability: Separate concerns for easier testing
Conclusion
The OperationRunnerState pattern isn't just a coding technique, it's a strategic approach to building more robust Flutter applications.Integrating it into your flutter app means you can simplify asynchronous operations while ensuring robust error handling and a better user experience.
If you’re working on a Flutter app that relies heavily on API calls or other async tasks, adopting this framework will save you time and make your codebase cleaner and more maintainable.