2 July 2024 · 2 min read
Flutter Generic Response: Cleaner API Request Handling
A GenericResponse class for Flutter that wraps API results, with Dio, error handling, a ViewModel, UI code, tests, interceptors, caching and retries.
Most Flutter apps talk to an API, and how you handle the results of those requests decides whether the user sees the right thing. A generic response class wraps the result of a request so that success and failure come back in one standard format.
Why use a generic response
- Less repeated code. Error and success cases that show up everywhere get handled in one place.
- Readability. The code is easier to read and to debug.
- Central error handling. All requests share one error handling mechanism.
- Flexibility. The result can be of any type (String, List, Map and so on).
The GenericResponse class
We define a class called GenericResponse. It holds the result of the request, an error message and whether the request succeeded.
class GenericResponse<T> {
final T? data;
final String? errorMessage;
final bool isSuccessful;
GenericResponse(this.data, this.errorMessage, {this.isSuccessful = false});
}
Here is the class with factory constructors for the two cases:
class GenericResponse<T> {
final T? data;
final String? errorMessage;
final bool isSuccessful;
GenericResponse(this.data, this.errorMessage, {this.isSuccessful = false});
factory GenericResponse.success(T data) {
return GenericResponse(data, null, isSuccessful: true);
}
factory GenericResponse.error(String errorMessage) {
return GenericResponse(null, errorMessage, isSuccessful: false);
}
}
Using it with API requests
Flutter has several libraries for HTTP requests. One of the most popular is Dio, a flexible and powerful HTTP client. The requests here use Dio, so first add it to pubspec.yaml as a dependency.
dependencies:
flutter:
sdk: flutter
dio: ^5.4.2
provider: ^6.1.2
GET request with Dio
import 'package:dio/dio.dart';
Future<GenericResponse<List<String>>> fetchItems() async {
try {
final response = await Dio().get('https://example.com/items');
if (response.statusCode == 200) {
List<String> items = List<String>.from(response.data);
return GenericResponse.success(items);
} else {
return GenericResponse.error('Failed to fetch items: ${response.statusCode}');
}
} catch (e) {
return GenericResponse.error('An error occurred: ${e.toString()}');
}
}
In this code the return type is wrapped: List<String> becomes GenericResponse<List<String>>. If something goes wrong, the function can pass the error to its caller through the return value. If we threw the error instead, the caller would need its own try-catch block to handle it. I don't want try-catch blocks everywhere. They make the code harder to read, and they are also the worse option for performance.
Error handling with an ErrorHandler class
To handle errors in API requests more cleanly, we can create an ErrorHandler class.
class ErrorHandler {
static String handle(dynamic error) {
if (error is DioError) {
switch (error.type) {
case DioErrorType.connectTimeout:
return 'Connection Timeout';
case DioErrorType.sendTimeout:
return 'Send Timeout';
case DioErrorType.receiveTimeout:
return 'Receive Timeout';
case DioErrorType.response:
return 'Received invalid status code: ${error.response?.statusCode}';
case DioErrorType.cancel:
return 'Request to API server was cancelled';
case DioErrorType.other:
return 'Connection to API server failed due to internet connection';
}
}
return 'Unexpected error occurred';
}
}
It finds out what kind of error the catch block received and returns the matching message. For a more detailed look at error handling, see this post: https://medium.com/@mohammadjoumani/error-handling-in-flutter-a1dfe81a2e0
GenericResponse in the ViewModel
The ViewModel connects the data layer to the UI layer. It manages the API calls and gives the UI the data it needs.
class ItemsViewModel extends ChangeNotifier {
List<String> items = [];
bool isLoading = false;
Future<void> loadItems() async {
isLoading = true;
notifyListeners();
final response = await fetchItems();
if (response.isSuccessful) {
items = response.data!;
} else {
// Handle the error message
}
isLoading = false;
notifyListeners();
}
}
GenericResponse in the UI layer
The UI layer updates the interface from what the ViewModel provides.
class ItemsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => ItemsViewModel(),
child: Consumer<ItemsViewModel>(
builder: (context, viewModel, child) {
if (viewModel.isLoading) {
return Center(child: CircularProgressIndicator());
} else if (viewModel.items.isEmpty) {
return Center(child: Text('No items available'));
} else {
return ListView.builder(
itemCount: viewModel.items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(viewModel.items[index]));
},
);
}
},
),
);
}
}
Showing success and error states
To show the result of an API call to the user, we can use a SnackBar or a dialog.
void showSnackBar(BuildContext context, String message, {bool isError = false}) {
final snackBar = SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : Colors.green,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
Best practices
Avoiding repeated code
Move shared functionality into helper classes or methods.
Extending GenericResponse
You can extend and customize the structure for your needs. For example, you can add extra fields like metaData.
Unit tests
Unit tests confirm that the code works, and the GenericResponse structure makes them easier to write.
import 'package:flutter_test/flutter_test.dart';
void main() {
test('GenericResponse success case', () {
final response = GenericResponse.success('Data loaded');
expect(response.isSuccessful, true);
expect(response.data, 'Data loaded');
expect(response.errorMessage, null);
});
test('GenericResponse error case', () {
final response = GenericResponse.error('Failed to load data');
expect(response.isSuccessful, false);
expect(response.data, null);
expect(response.errorMessage, 'Failed to load data');
});
}
More advanced topics
Interceptors
Interceptors catch API requests and responses so you can process them.
class LoggingInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
print('Request: ${options.method} ${options.path}');
super.onRequest(options, handler);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
print('Response: ${response.statusCode} ${response.data}');
super.onResponse(response, handler);
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
print('Error: ${err.message}');
super.onError(err, handler);
}
}
Caching
Storing API responses can improve the performance of the app. There are several strategies and libraries for it.
class CacheManager {
final Map<String, dynamic> _cache = {};
dynamic get(String key) => _cache[key];
void set(String key, dynamic value) {
_cache[key] = value;
}
Future<GenericResponse<List>> fetchItemsWithCache() async {
final cacheManager = CacheManager();
final cacheKey = 'items';
if (cacheManager.get(cacheKey) != null) {
return GenericResponse.success(List.from(cacheManager.get(cacheKey)));
}
try {
final response = await Dio().get("https://example.com/items");
if (response.statusCode == 200) {
List items = List.from(response.data);
cacheManager.set(cacheKey, items);
return GenericResponse.success(items);
} else {
return GenericResponse.error('Failed to fetch items: ${response.statusCode}');
}
}catch (e) {
return GenericResponse.error('An error occurred: ${e.toString()}');
}
}
}
Retries
A retry mechanism tries failed requests again. It helps especially with network problems or temporary server errors.
class RetryInterceptor extends Interceptor {
final int maxRetries;
final int retryDelay;
RetryInterceptor({this.maxRetries = 3, this.retryDelay = 1000});
@override
void onError(DioError err, ErrorInterceptorHandler handler) async {
var retryCount = 0;
while (retryCount < maxRetries && shouldRetry(err)) {
retryCount++;
await Future.delayed(Duration(milliseconds: retryDelay));
try {
final response = await Dio().request(
err.requestOptions.path,
options: err.requestOptions,
);
handler.resolve(response);
return;
} catch (e) {
continue;
}
}
handler.next(err);
}
bool shouldRetry(DioError err) {
return err.type == DioErrorType.other ||
err.type == DioErrorType.connectTimeout ||
err.type == DioErrorType.receiveTimeout;
}
}
final dio = Dio();
dio.interceptors.add(RetryInterceptor());
Full example
Here is an app that puts the pieces together. You can copy it and try it directly, though I recommend writing it yourself to understand it better. Remember to add the provider package to your pubspec.yaml.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ItemsPage(),
);
}
}
void main() {
runApp(MyApp());
}
class ItemsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => ItemsViewModel(),
child: Consumer<ItemsViewModel>(
builder: (context, viewModel, child) {
if (viewModel.isLoading) {
return Center(child: CircularProgressIndicator());
} else if (viewModel.items.isEmpty) {
return Center(child: Text('No items available'));
} else {
return ListView.builder(
itemCount: viewModel.items.length,
itemBuilder: (context, index) {
return ListTile(title: Text(viewModel.items[index]));
},
);
}
},
),
);
}
}
class ItemsViewModel extends ChangeNotifier {
List<String> items = [];
bool isLoading = false;
ItemsViewModel() {
loadItems();
}
Future<void> loadItems() async {
isLoading = true;
notifyListeners();
final response = await fetchItemsWithCache();
if (response.isSuccessful) {
items = response.data!;
} else {
showSnackBar(response.errorMessage!);
}
isLoading = false;
notifyListeners();
}
void showSnackBar(String message, {bool isError = false}) {
// Show the snackbar
}
}
Future<GenericResponse<List<String>>> fetchItemsWithCache() async {
final cacheManager = CacheManager();
final cacheKey = 'items';
if (cacheManager.get(cacheKey) != null) {
return GenericResponse.success(List<String>.from(cacheManager.get(cacheKey)));
}
try {
final response = await Dio().get('https://example.com/items');
if (response.statusCode == 200) {
List<String> items = List<String>.from(response.data);
cacheManager.set(cacheKey, items);
return GenericResponse.success(items);
} else {
return GenericResponse.error('Failed to fetch items: ${response.statusCode}');
}
} catch (e) {
return GenericResponse.error('An error occurred: ${e.toString()}');
}
}