Flutter: Caching API Response — Coding Guide

--

Cache handling in flutter application using dio and flutter_cache_manager
Caching API Response

In this guide, you’ll learn how to implement API response caching in your Flutter application.

We’ll skip the theoretical aspects, such as:

  • What caching is
  • Why and when caching is beneficial
  • The pros and cons of caching

These topics can easily be explored through online resources or AI tools like ChatGPT. Here, we’ll dive straight into the practical implementation of caching in Flutter.

Let’s Get Started </> 🚀

We will use an interceptor in-network calls to intercept each request, save the response, and return cached data in case of a failure. For this, we’ll use the following packages:

Note: We are not using Hive or dio_http_cache, as these libraries have not been actively maintained for a long time at the time of writing this blog.

Code Walkthrough

Step: 1 — Abstract Data Model

File: base_data_model.dart
Defines an abstract base model for data objects, providing a factory constructor for deserializing JSON into specific child model types.

import 'package:social_post/post_model.dart';

abstract class BaseDataModel {
const BaseDataModel();

Map<String, dynamic> toJson();

// Factory constructor to decide the child type
factory BaseDataModel.fromJson(Type type, Map<String, dynamic> json) {
if (type == PostModel) {
return PostModel.fromJson(json);
}
throw Exception('$type is not handled.');
}
}

Step: 2 — API Response Model

File: post_model.dart
Create a model class by extending BaseDataModel. This file represents the structure of the API response as a Dart object. It uses JSON annotations for seamless serialization and deserialization.

import 'package:json_annotation/json_annotation.dart';
import 'base_data_model.dart';

part 'post_model.g.dart';

@JsonSerializable()
class PostModel extends BaseDataModel {
final int? id;
final String? title;
final String? body;

PostModel(this.id, this.title, this.body);

factory PostModel.fromJson(Map<String, dynamic> json) =>
_$PostModelFromJson(json);
@override
Map<String, dynamic> toJson() => _$PostModelToJson(this);
}

Step: 3 — API Response Model

File: network_result.dart
This file standardizes the structure of API responses by wrapping them in an NetworkResult object. It indicates whether the response came from the network or cache.

import 'package:dio/dio.dart';

enum ResponseSource { network, local }

class NetworkResult<T> {
final T? data;
final ResponseSource? source;
final String? error;
final DioExceptionType? errorType;

NetworkResult._({this.data, this.source, this.error, this.errorType});

// Factory constructor for success
factory NetworkResult.success(T data, ResponseSource source) {
return NetworkResult._(data: data, source: ResponseSource.network);
}

// Factory constructor for failure
factory NetworkResult.failure(
T? data,
ResponseSource source,
String error,
DioExceptionType errorType,
) {
return NetworkResult._(
data: data, source: source, error: error, errorType: errorType);
}

bool get isSuccess => data != null;

bool get isFailure => error != null;
}

Step: 4 — Cache Management and Error Handling

File: api_call_helper.dart
This file manages cached responses and provides a fallback mechanism when the API call fails. It decides whether to return network data or fallback cached data.

  • Wraps API calls in NetworkResult to manage success and failure cases.
  • If an API call fails, retrieves the cached response for the corresponding request key.
  • Provides custom error messages based on the exception type.
import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:social_post/base_data_model.dart';

import 'network_result.dart';

class ApiCallHelper {
final cacheManager = DefaultCacheManager();

// Wraps a Future in a NetworkResult to handle success and failure centrally.
Future<NetworkResult<T>> makeApiCall<T extends BaseDataModel>(Future<T> call,
{bool returnCacheIfFailed = true}) async {
try {
final networkResult = await call;
debugPrint('networkResult - $networkResult');

return NetworkResult.success(networkResult, ResponseSource.network);
} on DioException catch (e) {
// Check if data exists in cache
T? cacheResponse;

if (returnCacheIfFailed) {
cacheResponse = await _getCachedResponse(e.requestOptions.uri.path);
}

String errorMessage = _getErrorMessage(e);
return NetworkResult.failure(
cacheResponse, ResponseSource.local, errorMessage, e.type);
} catch (e) {
return NetworkResult.failure(
null,
ResponseSource.local,
'Unexpected error occurred: ${e.toString()}',
DioExceptionType.unknown,
);
}
}

Future<T?> _getCachedResponse<T>(String cacheKey) async {
var file = await cacheManager.getFileFromCache(cacheKey);
var cacheResponseFile = file?.file;
T? cacheResponse;
final String? response = await cacheResponseFile?.readAsString();
if (response != null) {
final data = await json.decode(response);
cacheResponse = BaseDataModel.fromJson(T, data) as T;
}
return cacheResponse;
}

String _getErrorMessage(DioException error) {
String? errorMessage0 = 'Something went wrong. Please try again.';
try {
var jsonErrorData = Map<String, dynamic>.from(error.response?.data);
String? message = jsonErrorData['error'];
errorMessage0 = message;
} catch (e) {
errorMessage0 = 'Something went wrong. Please try again.';
}

if (errorMessage0 != null) {
return errorMessage0;
}

if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return 'Connection timeout. Please try again later.';
} else if (error.response != null) {
// Map status code to custom error messages.
switch (error.response?.statusCode) {
case 400:
return 'Bad request. Please try again.';
case 401:
return 'Unauthorized. Please check your credentials.';
case 404:
return 'Resource not found.';
case 500:
return 'Internal server error. Please try again later.';
default:
return 'Error: ${error.response?.statusMessage}';
}
} else {
return 'Something went wrong. Please try again.';
}
}
}

Step: 5— Dio setup and API Communication

File: dio_api_client.dart
This file handles all API communication using the Dio package. It includes interceptors to cache the API responses as JSON files using flutter_cache_manager.

  • Configures the Dio instance for the base URL and logging.
  • Add Interceptors log requests and responses and save API responses to cache.
import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:social_post/post_model.dart';

class DioApiClient {
final String baseUrl = 'https://jsonplaceholder.typicode.com/';
late final Dio _dio;
final cacheManager = DefaultCacheManager();

DioApiClient() {
_dio = Dio(
BaseOptions(baseUrl: baseUrl),
);
_addInterceptors(_dio);
}

void _addInterceptors(Dio dio) {
dio.interceptors.add(LogInterceptor(
request: true,
requestHeader: true,
requestBody: true,
responseHeader: true,
responseBody: true,
error: true,
));

dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// you can add you api headers like auth key etc. here.
handler.next(options);
},
onResponse: (response, handler) async {
// Save response to cache as json file
debugPrint('path - ${response.realUri.path}');
await cacheManager.putFile(
response.realUri.path,
utf8.encode(jsonEncode(response.data)),
fileExtension: 'json',
);

return handler.next(response);
},
onError: (error, handler) {
// Handle errors globally if needed
return handler.next(error);
},
));
}

Dio get dio => _dio;

Future<PostModel> fetchPosts() async {
var response = await dio.get('posts/1');
return PostModel.fromJson(response.data);
}
}

That’s it! Everything is now centralized and ready to be used for all API calls. From here, you simply need to make the API call and handle the response according to your specific requirements.

Final Step— Make API call and handle response

main.dart: This file is the app’s UI layer and entry point. It handles user interactions like fetching data or clearing the cache and displays the API response and its source (network or cache).

import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:social_post/api_call_helper.dart';
import 'package:social_post/dio_api_client.dart';
import 'package:social_post/post_model.dart';

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
const MyApp({super.key});

@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
String? apiResponse;
String? source;
final cacheManager = DefaultCacheManager();
DioApiClient apiClient = DioApiClient();

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Caching API Response')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
OutlinedButton(
onPressed: () => fetchDataWithCache(),
child: const Text('Fetch Data')),
OutlinedButton(
onPressed: () => cacheManager.emptyCache(),
child: const Text('Clear cache')),
],
),
const SizedBox(height: 16),
_buildInfoCard('Source: $source', Colors.red),
const SizedBox(height: 16),
_buildInfoCard(apiResponse ?? 'Data not available', Colors.blue),
],
),
),
),
);
}

Widget _buildInfoCard(String text, Color color) {
return Container(
alignment: Alignment.topLeft,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: color),
borderRadius: const BorderRadius.all(Radius.circular(6)),
),
child: Text(text, style: const TextStyle(fontSize: 16)),
);
}

void fetchDataWithCache() async {
setState(() {
source = 'Fetching';
apiResponse = null;
});

final response =
await ApiCallHelper().makeApiCall<PostModel>(apiClient.fetchPosts());

setState(() {
source = response.source.toString();
apiResponse = response.data?.toJson().toString();
});
}
}

Conclusion:

This modular approach ensures:

  • A clean separation of concerns.
  • Efficient API caching with fallback mechanisms.
  • Scalable and reusable architecture.

Try this approach in your app to enhance performance and improve the user experience!

Thanks for reading! If you found this guide helpful, don’t forget to follow me on Medium and X(Twitter) for more coding tutorials, tips and tricks. Happy coding! 😊

--

--

Rahul Rathore (Flutter and Android Developer)
Rahul Rathore (Flutter and Android Developer)

No responses yet