Efficient API Calls in Flutter

Adedola Owen Abaru
8 min readJan 21, 2024

--

If you started your journey into building Flutter apps as I did, you’ve probably fallen into the same pitfalls I fell into. My code lacked structure, I was passing context into API calls, and making API calls in the UI, and everything was a hot mess. I went live with the app but making updates was a pain.

Fast forward a few years into my flutter journey and I can now confidently say, my code is no longer a hot mess, in this article, I will be giving you a clean trick into how I organize my codebase, the focus would be mainly on how I make API calls and connect it to my favorite state management solution(Bloc).

Now hold your horses, you may not like Bloc but I assure you this would work with any SM technique you prefer, be it Provider, GetX, Stacked, or even Riverpod🤮.

calm down

Now I want you to also keep in mind, that this is not the only way or the best way, but it sure is a good way to organize your code

Now, why do we need to organize our code, well because Darth Vader comes after all who write spaghetti, and also because organizing your code and adhering to a certain structure makes your life as a developer easier, and better.

In this article, we’re going to be making use of a couple of tools, first is the free API we’ll be using. Now, I'm going to leave the task of getting the API key up to you and forge ahead

The first thing I want is a structure that defines every response I can get from the API, and for this particular API, the documentation does a great job of telling me exactly what I should expect so I'll start by creating a response object, to handle everything


class ApiResponse<T> {
T? data;
String? statusCode;
bool? success;
String? statusMessage;

ApiResponse({
this.data,
this.statusCode,
this.success,
this.statusMessage,
});

@override
String toString() {
return 'ApiResponse<$T>{data: $data, statusCode: $statusCode, success: $success, statusMessage: $statusMessage}';
}

factory ApiResponse.fromError(String message, String statusCode) {
return ApiResponse(
success: false,
statusCode: statusCode,
statusMessage: message,
);
}
}

you’ll notice the response has a data property called T, which at the moment is not an object, this is generic and makes the ApiResponse class reusable with different objects

we also need an object to handle lists of data


@JsonSerializable(
createToJson: false,
genericArgumentFactories: true,
fieldRename: FieldRename.snake)
class ListResponse<T> {
final int page;
final List<T> results;
final int totalPages;
final int totalResults;

ListResponse({
required this.page,
required this.results,
required this.totalPages,
required this.totalResults,
});

@override
String toString() {
return 'ListResponse<$T>{page: $page, results: $results, totalPages: $totalPages, totalResults: $totalResults}';
}

@override
factory ListResponse.fromJson(
Map<String, dynamic> json, T Function(Object? json) fromJsonT) {
return _$ListResponseFromJson(json, fromJsonT);
}

The next thing in our itinerary is to create an object to handle our network calls, I would be using dio for this but instead of using it directly, it would be used over a layer of abstraction, and to create this abstraction I need to put a couple of things into consideration

what do I want my network class to do, and what are the properties I want it to have, some of these may include methods and properties like

  1. set token
  2. remove token
  3. make requests (with or without FormData)
  4. show a loading indicator when making a request that should block the interaction
  5. handle exceptions

these are just a few of the capabilities I might need from a networking object. we’ll go ahead to create an object to this effect


abstract class ApiClient {
Future<ApiResponse<T>> request<T>({
required String path,
required MethodType method,
Map<String, dynamic>? payload,
Map<String, dynamic>? queryParameters,
T Function(Map<String, dynamic> json)? fromJson,
bool? showLoader,
});

void setToken(String token);
void removeToken();
String handleException(Exception exception);
}

the MethodType parameter is an enum and below are the values

enum MethodType { get, post, put, delete, patch }

and now for the implementation, this is going to be a bit much, but take your time and dive into how my mind works, trust me, it’s good


class DioClient implements ApiClient {
late Dio _client;

DioClient() {
_client = Dio()
..options.baseUrl = 'https://api.themoviedb.org/3/'
..interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.queryParameters['api_key'] =
'your-api-key';
return handler.next(options);
},
));
}

@override
void removeToken() {
_client.options.headers.remove('Authorization');
}

@override
void setToken(String token) {
_client.options.headers['Authorization'] = 'Bearer $token';
}

@override
Future<ApiResponse<T>> request<T>({
required String path,
required MethodType method,
Map<String, dynamic>? payload,
Map<String, dynamic>? queryParameters,
T Function(Map<String, dynamic> json)? fromJson,
bool? showLoader,
}) async {
throw UnimplementedError();
}
}

this is the implemented class, the setToken and removeToken would not be used in this article because the API we’re using does not use them, it uses a query parameter instead, which is why I added an interceptor to handle that so we don’t keep doing it manually every time

Below, we have the implemented request method



class DioClient implements ApiClient {
late Dio _client;

DioClient() {
_client = Dio()
..options.baseUrl = 'https://api.themoviedb.org/3/'
..interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
options.queryParameters['api_key'] =
'your-api-key';
//TODO: this should come from an env file, never hardcode stuff like these
return handler.next(options);
},
));
}

@override
void removeToken() {
_client.options.headers.remove('Authorization');
}

@override
void setToken(String token) {
_client.options.headers['Authorization'] = 'Bearer $token';
}

@override
Future<ApiResponse<T>> request<T>({
required String path,
required MethodType method,
Map<String, dynamic>? payload,
Map<String, dynamic>? queryParameters,
T Function(Map<String, dynamic> json)? fromJsonT,
bool? showLoader,
}) async {
ApiResponse<T> apiResponse;
Response response;
try {
switch (method) {
case MethodType.get:
response = await _client.get(
path,
data: payload,
queryParameters: queryParameters,
);
break;
case MethodType.post:
response = await _client.post(
path,
data: payload,
queryParameters: queryParameters,
);
break;
case MethodType.put:
response = await _client.put(
path,
data: payload,
queryParameters: queryParameters,
);
break;
case MethodType.delete:
response = await _client.delete(
path,
data: payload,
queryParameters: queryParameters,
);
break;
case MethodType.patch:
response = await _client.patch(
path,
data: payload,
queryParameters: queryParameters,
);
break;
}


apiResponse = ApiResponse(
data: fromJsonT?.call(response.data),
statusCode: response.statusCode.toString(),
success: true,
);
} on DioException catch (e) {
apiResponse = ApiResponse<T>.fromError(
(e.response?.data['status_message'] ?? e.message).toString(),
(e.response?.data?['status_code'] ?? e.response?.statusCode).toString(),
);
}
log(apiResponse.toString(), name: 'api_response');
return apiResponse;
}
}

For now, I’m going to use this object to call two endpoints, one that expects a list of an object and one that expects an object

//TrendingMovieModel

{
"adult": false,
"backdrop_path": "/rz8GGX5Id2hCW1KzAIY4xwbQw1w.jpg",
"genre_ids": [
28,
35,
80
],
"id": 955916,
"original_language": "en",
"original_title": "Lift",
"overview": "An international heist crew, led by Cyrus Whitaker, race to lift $500 million in gold from a passenger plane at 40,000 feet.",
"popularity": 2025.03,
"poster_path": "/gma8o1jWa6m0K1iJ9TzHIiFyTtI.jpg",
"release_date": "2024-01-10",
"title": "Lift",
"video": false,
"vote_average": 6.313,
"vote_count": 350
}

//MovieModel

{
"adult": false,
"backdrop_path": "/rz8GGX5Id2hCW1KzAIY4xwbQw1w.jpg",
"belongs_to_collection": null,
"budget": 0,
"genres": [
{
"id": 28,
"name": "Action"
},
{
"id": 35,
"name": "Comedy"
},
{
"id": 80,
"name": "Crime"
}
],
"homepage": "https://www.netflix.com/title/81446739",
"id": 955916,
"imdb_id": "tt14371878",
"original_language": "en",
"original_title": "Lift",
"overview": "An international heist crew, led by Cyrus Whitaker, race to lift $500 million in gold from a passenger plane at 40,000 feet.",
"popularity": 2025.03,
"poster_path": "/gma8o1jWa6m0K1iJ9TzHIiFyTtI.jpg",
"production_companies": [
{
"id": 28788,
"logo_path": null,
"name": "Genre Films",
"origin_country": "US"
},
{
"id": 101405,
"logo_path": null,
"name": "6th & Idaho",
"origin_country": "US"
},
{
"id": 40268,
"logo_path": "/shdAxUj8uF6exNLrF0kSqYVxCzG.png",
"name": "HartBeat Productions",
"origin_country": "US"
}
],
"production_countries": [
{
"iso_3166_1": "US",
"name": "United States of America"
}
],
"release_date": "2024-01-10",
"revenue": 0,
"runtime": 106,
"spoken_languages": [
{
"english_name": "English",
"iso_639_1": "en",
"name": "English"
},
{
"english_name": "Italian",
"iso_639_1": "it",
"name": "Italiano"
},
{
"english_name": "Spanish",
"iso_639_1": "es",
"name": "Español"
}
],
"status": "Released",
"tagline": "The heist begins at 40,000 ft.",
"title": "Lift",
"video": false,
"vote_average": 6.31,
"vote_count": 352
}

Here’s the Json response we would be working with mostly, I'm going to make this into a dart object, otherwise known as a class



import 'package:json_annotation/json_annotation.dart';

part 'model.g.dart';

@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class TrendingMovieModel {
final String? backdropPath;
final String? firstAirDate;
final List<int>? genreIds;
final int? id;
final String? name;
final List<String>? originCountry;
final String? originalLanguage;
final String? originalName;
final String? overview;
final double? popularity;
final String? posterPath;
final double? voteAverage;
final int? voteCount;

TrendingMovieModel({
this.backdropPath,
this.firstAirDate,
this.genreIds,
this.id,
this.name,
this.originCountry,
this.originalLanguage,
this.originalName,
this.overview,
this.popularity,
this.posterPath,
this.voteAverage,
this.voteCount,
});

@override
String toString() {
return 'TrendingMovieModel{backdropPath: $backdropPath, firstAirDate: $firstAirDate, genreIds: $genreIds, id: $id, name: $name, originCountry: $originCountry, originalLanguage: $originalLanguage, originalName: $originalName, overview: $overview, popularity: $popularity, posterPath: $posterPath, voteAverage: $voteAverage, voteCount: $voteCount}';
}

factory TrendingMovieModel.fromJson(Map<String, dynamic> json) {
return _$TrendingMovieModelFromJson(json);
}
}


@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class MovieModel {
final bool? adult;
final String? backdropPath;
final List<GenreModel>? genres;
final int? id;
final String? originalLanguage;
final String? originalTitle;
final String? overview;
final double? popularity;
final String? posterPath;
final String? releaseDate;
final String? title;
final bool? video;
final double? voteAverage;
final int? voteCount;
final List<ProductionCompanies>? productionCompanies;
final List<ProductionCountries>? productionCountries;
final List<SpokenLanguages>? spokenLanguages;

MovieModel({
this.adult,
this.backdropPath,
this.genres,
this.id,
this.originalLanguage,
this.originalTitle,
this.overview,
this.popularity,
this.posterPath,
this.releaseDate,
this.title,
this.video,
this.voteAverage,
this.voteCount,
this.productionCompanies,
this.productionCountries,
this.spokenLanguages,
});

@override
String toString() {
return 'MovieModel{adult: $adult, backdropPath: $backdropPath, genres: $genres, id: $id, originalLanguage: $originalLanguage, originalTitle: $originalTitle, overview: $overview, popularity: $popularity, posterPath: $posterPath, releaseDate: $releaseDate, title: $title, video: $video, voteAverage: $voteAverage, voteCount: $voteCount, productionCompanies: $productionCompanies, productionCountries: $productionCountries, spokenLanguages: $spokenLanguages}';
}

factory MovieModel.fromJson(Map<String, dynamic> json) {
return _$MovieModelFromJson(json);
}
}


@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class GenreModel {
final int? id;
final String? name;

GenreModel({
this.id,
this.name,
});

@override
String toString() {
return 'GenreModel{id: $id, name: $name}';
}

factory GenreModel.fromJson(Map<String, dynamic> json) {
return _$GenreModelFromJson(json);
}
}

@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class ProductionCompanies {
final int? id;
final String? logoPath;
final String? name;
final String? originCountry;

ProductionCompanies({
this.id,
this.logoPath,
this.name,
this.originCountry,
});

@override
String toString() {
return 'ProductionCompanies{id: $id, logoPath: $logoPath, name: $name, originCountry: $originCountry}';
}

factory ProductionCompanies.fromJson(Map<String, dynamic> json) {
return _$ProductionCompaniesFromJson(json);
}
}

@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class ProductionCountries {
final String? iso31661;
final String? name;

ProductionCountries({
this.iso31661,
this.name,
});

@override
String toString() {
return 'ProductionCountries{iso31661: $iso31661, name: $name}';
}

factory ProductionCountries.fromJson(Map<String, dynamic> json) {
return _$ProductionCountriesFromJson(json);
}
}

@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake)
class SpokenLanguages {
final String? englishName;
final String? iso6391;
final String? name;

SpokenLanguages({
this.englishName,
this.iso6391,
this.name,
});

@override
String toString() {
return 'SpokenLanguages{englishName: $englishName, iso6391: $iso6391, name: $name}';
}

factory SpokenLanguages.fromJson(Map<String, dynamic> json) {
return _$SpokenLanguagesFromJson(json);
}
}

We have our models in place, now we need to create an abstraction to handle all calls to our network requests, I call this the RemoteDataSource


abstract class RemoteDataSource {
Future<ApiResponse<ListResponse<TrendingMovieModel>>> getMovies();
Future<ApiResponse<MovieModel>> getTvShows();
}

class RemoteDataSourceImpl implements RemoteDataSource {
final ApiClient apiClient;

RemoteDataSourceImpl({required this.apiClient});

@override
Future<ApiResponse<ListResponse<TrendingMovieModel>>> getMovies() {
return apiClient.request(
method: MethodType.get,
path: 'discover/tv',
fromJsonT: (json) => ListResponse<TrendingMovieModel>.fromJson(
json,
(json) =>
TrendingMovieModel.fromJson(json as Map<String, dynamic>),
));
}

@override
Future<ApiResponse<MovieModel>> getTvShows() {
return apiClient.request(
method: MethodType.get,
path: 'movie/955916',
fromJsonT: (json) => MovieModel.fromJson(json),
);
}
}

This article is long enough, I'm going to continue this in another article shortly, and there we’re going to handle loading app information from the env file, setting up a bloc to handle the API calls, showing a loader, showing error messages, and working on some simple UI to handle the data

But what we have right now, is enough information to make API calls, and forget about manually handling a lot of stuff, while letting Generics come into action on our behalf. Now, you may not have to follow this exact pattern, you could even make it better.

If you enjoyed this article, kindly follow me on my socials

  1. Twitter
  2. LinkedIn
  3. Instagram
  4. Whatsapp

--

--