DEV Community

Cover image for Streamlining Asynchronous Operations in Flutter with Error Handling and Retry Mechanism
ASAMOAH MICHAEL
ASAMOAH MICHAEL

Posted on

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<T> = Future<T> Function();
This simple typedef defines Operation<T> as a function that returns a Future<T>, allowing flexibility to execute any async task.

Executing an Operation

abstract class OperationRunnerState<T extends StatefulWidget> extends State<T> {
  @protected
  Future<T?> runOperation<T>(
    Operation operation, {
    BuildContext? contextE,
    bool showLoader = true,
  }) async {
    contextE ??= context;

    Future<void> 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

void _handleException(
  BuildContext context, 
  String? message, 
  bool showLoader,
  Future<void> Function()? retryCallback
) async {
  if (showLoader) Navigator.pop(context);
  _handleErrors(context, message ?? "An error occurred", retryCallback);
}

Future<Object?> _handleErrors(
  BuildContext context, 
  String message,
  Future<void> Function()? retryCallback
) async {
  final shouldRetry = await context.pushNamed(errorScreenRoute, extra: {
    "errorMessage": message,
    "onRetry": retryCallback,
  });

  if (shouldRetry && retryCallback != null) {
    await retryCallback(); 
  }

  return shouldRetry;
}
Enter fullscreen mode Exit fullscreen mode

Showing a Loading Indicator

void showLoading(BuildContext context, {String? loadingText}) {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (context) {
      return LoadingIndicator(loadingText: loadingText);
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage Example
Here's how you can use OperationRunnerState in your widget:

class ProductListScreen extends StatefulWidget {
  @override
  _ProductListScreenState createState() => _ProductListScreenState();
}

class _ProductListScreenState extends OperationRunnerState<ProductListScreen> {
  List<Product> products = [];

  @override
  void initState() {
    super.initState();
    _fetchProducts();
  }

  Future<void> _fetchProducts() async {
    final result = await runOperation<List<Product>>(
      () => 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]),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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. 🚀

Do you have a different approach? Share in the comment section.

Top comments (0)