In the world of modern app development, APIs bridge the gap between your app and the data it needs. They are the backbone of app development, powering everything from data fetching to real-time updates, and enabling developers to build richer, more functional and dynamic user experiences.
In this blog post we’ll look at best practices for integrating APIs in Flutter, complete with practical examples to help you seamlessly connect your app to the world of data. We will be using Bloc, or its simpler version, Cubit to manage the data coming from our example API.
Prerequisite
I’m going to assume in this blog post that you are familiar with Bloc. If not you can checkout my blog post on Bloc.
Before we dive into integrating APIs with Flutter, it’s important to understand the fundamentals of how API’s work and their role in app development.
What is an API
As stated in the introduction above, API stands for Application Programming Interface. They allow your app to communicate with external services or systems. APIs define how requests for data or functionality are made and how responses are returned. A good example is when an app fetches the current weather, or submits a form online; it’s communicating with a server via API.
Common API concepts
Endpoints
An endpoint is a URL that specifies where a particular resource can be accessed. In an example weather API, https://api.exampleweather.com/current can be the endpoint to fetch the current weather data
HTTP Methods
Hypertext Transfer Protocol or HTTP is the protocol used to load webpages using hypertext links. HTTPs stands for Hypertext Transfer Protocol Secure and is the secure version of the HTTP protocol. To define the type of action being performed API use HTTP methods
- GET: Fetches or retrieves data
- POST: Send data to create a new resource
- PUT: Update an existing resource
- DELETE: Remove a resource.
Request and Response
A request a what an app sends to the API and it may include the endpoint, HTTP method, and optional data.
A response is what the API returns, typically in JSON format, which is then parsed and used by your app.
JSON Format
JSON stands for JavaScript Object Notation and is the most common data format used by APIs since it’s light wait easy to use and well suited for communication between an app and a server. An example of data formatted in JSON format is
{
"id": 1,
"name": "John Doe",
"email": "[email protected]"
}
HTTP Status Code
APIs return status codes to indicate the result of a request. The most common ones are
- 200 OK: The request was successful
- 400 Bad Request: There was an issue with the request
- 401 Unauthorized: Authentication is required.
- 500 Internal Server Error: Something went wrong on the server
Knowing these basic API concepts helps to troubleshoot issues, write efficient code, and handle API response well.
Now that we understand the basics, we are ready to look at how to handle API integration in an app in Flutter.
Getting Started
Let’s begin by opening Android Studio and creating a new Flutter Project flutter_api_tutorial.
Dependencies
Add the http package and flutter_bloc package to the pubspec.yaml file. Make sure you run flutter pub get
dependencies:
flutter_bloc: ^8.0.0
http: ^1.2.2
Now we are ready to dive in!
Making API Requests
Flutter makes it easy to fetch data from an API with the http package. In the lib folder, create the file api_service.dart. Import http package as http and define the ApiService class.
import 'package:http/http.dart' as http;
class ApiService{}
Making a GET Request
A GET request is used to fetch data from an API.
Let’s create the fetchData() function; it will fetch data from a placeholder API
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
Future<void> fetchData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
print(data); // Parsed JSON data
} else {
print('Error: ${response.statusCode}');
}
} catch (e) {
print('Failed to fetch data: $e');
}
}
}
Uri.parse()
creates a valid URI object from the endpoint URL.http.get()
sends the GET request.response.statusCode
checks the HTTP status code (e.g.,200 OK
for success).jsonDecode()
parses the JSON response into a Dart object.
Making a POST Request
A POST request is used to send data to an API. Here’s how to send JSON data
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
...
Future<void> postData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final body = jsonEncode({
'title': 'Flutter API Example',
'body': 'This is a sample post',
'userId': 1,
});
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: body,
);
if (response.statusCode == 201) {
final responseData = jsonDecode(response.body);
print('Post created: $responseData');
} else {
print('Error: ${response.statusCode}');
}
} catch (e) {
print('Failed to post data: $e');
}
}
}
- Set
headers
to specify the content type (application/json
for JSON data). - Use
jsonEncode()
to convert the Dart object to JSON format before sending.
Making a PUT Request
A PUT
request is used to update existing resources. Here’s how:
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
...
Future<void> updatePost(int postId) async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts/$postId');
final body = jsonEncode({
'title': 'Updated Title',
'body': 'Updated body content',
'userId': 1,
});
try {
final response = await http.put(
url,
headers: {'Content-Type': 'application/json'},
body: body,
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
print('Post updated: $data');
} else {
print('Error: ${response.statusCode}');
}
} catch (e) {
print('Failed to update post: $e');
}
}
}
http.put()
replaces the entire resource with the provided data.- Ensure the endpoint includes the resource identifier (e.g.,
/$postId
).
Making DELETE Request
A DELETE
request is used to remove resources
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiService {
...
Future<void> deletePost(int postId) async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts/$postId');
try {
final response = await http.delete(url);
if (response.statusCode == 200) {
print('Post deleted successfully');
} else {
print('Error: ${response.statusCode}');
}
} catch (e) {
print('Failed to delete post: $e');
}
}
}
- Use
http.delete()
to send the DELETE request. - Most APIs return a
200 OK
or204 No Content
status code for successful deletions.
Handling API Responses
When working with APIs, it’s important to handle responses properly to ensure your app processes and displays the data correctly. Most APIs return data in JSON format, which needs to be parsed into Dart objects for seamless use in your app.
To work with this data in Flutter, we’ll define a model class. In the lib folder create a file post.dart and add the following
import 'dart:convert';
class Post {
final int id;
final String title;
final String body;
final int userId;
Post({
required this.id,
required this.title,
required this.body,
required this.userId,
});
// Factory method to parse JSON into a Dart object
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'],
title: json['title'],
body: json['body'],
userId: json['userId'],
);
}
String toJson() => jsonEncode(toMap());
Map<String, dynamic> toMap() => {
'id': id,
'title': title,
'body': body,
'userId': userId,
};
}
We can now use this model to parse the JSON response.
In api_serice.dart, Let’s rewrite the fetchData() function to parse the data received using the Post model we created. fetchData() will return a list of posts.
import 'dart:convert';
import 'package:flutter_api_tutorial/post.dart';
import 'package:http/http.dart' as http;
class ApiService {
Future<List<Post>> fetchData() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
try {
final response = await http.get(url);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body); // New
final posts = data.map((e) => Post.fromJson(e)).toList(); // New
print(posts); // New
print(data); // Parsed JSON data
return posts;
} else {
print('Error: ${response.statusCode}');
throw Exception('Failed to fetch posts: ${response.statusCode}');
}
} catch (e) {
print('Failed to fetch data: $e');
throw Exception('Network error: $e');
}
}
}
Let’s also rewrite postData() and updatePost() to receive data from outside api_service.dart
import 'dart:convert';
import 'package:flutter_api_tutorial/post.dart';
import 'package:http/http.dart' as http;
class ApiService {
...
Future<void> postData(Post post) async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts');
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: post.toJson(),
);
if (response.statusCode == 201) {
final responseData = jsonDecode(response.body);
print('Post created: $responseData');
} else {
print('Error: ${response.statusCode}');
throw Exception('Failed to post data: ${response.statusCode}');
}
} catch (e) {
print('Failed to post data: $e');
throw Exception('Network error: $e');
}
}
Future<void> updatePost(Post post) async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts/${post.id}');
try {
final response = await http.put(
url,
headers: {'Content-Type': 'application/json'},
body: post.toJson(),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
print('Post updated: $data');
} else {
print('Error: ${response.statusCode}');
throw Exception('Failed to update post: ${response.statusCode}');
}
} catch (e) {
print('Failed to update post: $e');
throw Exception('Network error: $e');
}
}
Future<void> deletePost(int postId) async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts/$postId');
try {
final response = await http.delete(url);
if (response.statusCode == 200) {
print('Post deleted successfully');
} else {
print('Error: ${response.statusCode}');
throw Exception('Failed to delete post: ${response.statusCode}');
}
} catch (e) {
print('Failed to delete post: $e');
throw Exception('Network error: $e');
}
}
}
Integrate with the Rest of the App
As your app grows, scattering API calls across different parts of your codebase can make it unmanageable. An architecture for handling API calls ensures modularity and maintainability.
Use Repositories for abstraction
We will introduce a repository layer by creating the post_repository.dart file. The purpose of the repository is to bridge the API service and the rest of the app. It provides a clear interface to make it easier to swap the data source if needed like using a local database instead of an API
import 'package:flutter_api_tutorial/api_service.dart';
import 'package:flutter_api_tutorial/post.dart';
abstract class PostRepository {
Future<List<Post>> getPosts();
Future<void> submitPost(Post post);
Future<void> updatePost(Post post);
Future<void> deletePost(int postId);
}
class PostRepositoryImpl implements PostRepository {
final ApiService apiService;
PostRepositoryImpl(this.apiService);
@override
Future<List<Post>> getPosts() {
return apiService.fetchData();
}
@override
Future<void> submitPost(Post post) {
return apiService.postData(post);
}
@override
Future<void> updatePost(Post post) {
return apiService.updatePost(post);
}
@override
Future<void> deletePost(int postId) {
return apiService.deletePost(postId);
}
}
State management
As stated previously, we will be using Cubit to manage data coming from the repository. In the lib folder create a folder post cubit and create the files post_cubit.dart and post_state.dart.
post_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_api_tutorial/post.dart';
import 'package:flutter_api_tutorial/post_repository.dart';
part 'post_state.dart';
class PostCubit extends Cubit<PostState> {
final PostRepository postRepository;
PostCubit(this.postRepository) : super(PostInitial());
Future<void> loadPosts() async {
emit(FetchingPosts());
try {
final posts = await postRepository.getPosts();
emit(PostsFetched(posts: posts));
} catch (error) {
print('Error loading posts: $error');
emit(PostError(message: 'Error loading posts: $error'));
}
}
Future<void> submitPost(Post post) async {
emit(SubmittingPost());
try {
await postRepository.submitPost(post);
emit(PostSubmitted());
} catch (error) {
print('Error submitting post: $error');
emit(PostError(message: 'Error submitting post: $error'));
}
}
Future<void> updatePost(Post post) async {
emit(UpdatingPost());
try {
await postRepository.submitPost(post);
} catch (error) {
print('Error updating post: $error');
emit(PostError(message: 'Error updating post: $error'));
}
}
Future<void> deletePost(int postId) async {
emit(DeletingPost());
try {
await postRepository.deletePost(postId);
} catch (error) {
print('Error deleting post: $error');
emit(PostError(message: 'Error deleting post: $error'));
}
}
}
post_state.dart
part of 'post_cubit.dart';
@immutable
sealed class PostState {}
final class PostInitial extends PostState {}
final class FetchingPosts extends PostState {}
final class PostsFetched extends PostState {
PostsFetched({required this.posts});
final List<Post> posts;
}
final class SubmittingPost extends PostState {}
final class PostSubmitted extends PostState {}
final class UpdatingPost extends PostState {}
final class PostUpdated extends PostState {}
final class DeletingPost extends PostState {}
final class PostDeleted extends PostState {}
final class PostError extends PostState {
PostError({required this.message});
final String message;
}
Building the UI
Fetching and manipulating data is only half the job; displaying it in an engaging and user-friendly interface completes the picture. Let’s build a simple UI to showcase how API data can be rendered and managed effectively.
in the lib folder create the file post_list_screen.dart and inside it the stateful widget PostListScreen
import 'package:flutter/material.dart';
import 'package:flutter_api_tutorial/add_post_form.dart';
import 'package:flutter_api_tutorial/api_service.dart';
import 'package:flutter_api_tutorial/edit_post_form.dart';
import 'package:flutter_api_tutorial/post.dart';
import 'package:flutter_api_tutorial/post_cubit/post_cubit.dart';
import 'package:flutter_api_tutorial/post_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PostListScreen extends StatefulWidget {
const PostListScreen({super.key});
@override
State<PostListScreen> createState() => _PostListScreenState();
}
class _PostListScreenState extends State<PostListScreen> {
List<Post> posts = [];
@override
void initState() {
super.initState();
context.read<PostCubit>().loadPosts();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('API Tutorial'),
centerTitle: true,
),
body: SafeArea(
child: BlocConsumer<PostCubit, PostState>(
listener: (context, state) {
if (state is PostDeleted) {
print('Post deleted successfully');
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Post deleted successfully'),
),
);
context.read<PostCubit>().loadPosts();
}
if (state is PostsFetched) {
posts = state.posts;
}
},
builder: (context, state) {
if (state is FetchingPosts || state is DeletingPost || state is UpdatingPost) {
return const Center(child: CircularProgressIndicator());
}
if (state is PostError) {
return Center(
child: Text(state.message),
);
}
if (state is PostsFetched && state.posts.isNotEmpty) {
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return Card(
margin: const EdgeInsets.all(8),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 16.0,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.65,
child: Text(
post.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
await context.read<PostCubit>().deletePost(post.id);
},
),
SizedBox(
width: 10,
),
const Icon(Icons.edit),
],
),
],
),
subtitle: Text(post.body),
// trailing: const Icon(Icons.edit),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => PostCubit(
repository: PostRepositoryImpl(
ApiService(),
),
),
child: EditPostForm(
titleController: TextEditingController(text: post.title),
bodyController: TextEditingController(text: '${post.body}'),
),
),
),
);
},
),
);
},
);
} else {
return const Center(
child: Text('No Posts'),
);
}
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// Navigate to a form screen for creating posts
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BlocProvider(
create: (context) => PostCubit(
repository: PostRepositoryImpl(
ApiService(),
),
),
child: AddPostForm(),
),
),
);
},
child: const Icon(Icons.add),
),
);
}
}
BlocConsumer is a widget from the flutter_bloc package that exposes a builder and listener in order react to new states. BlocConsumer is analogous to a nested BlocListener and BlocBuilder but reduces the amount of boilerplate needed
The deleted button calls deletePost() from PostCubit.
The floating action button leads a form to add a new post. The edit button leads to a form to update an existing post. Let’s create the forms
add_post_form.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_api_tutorial/api_service.dart';
import 'package:flutter_api_tutorial/post.dart';
import 'package:flutter_api_tutorial/post_cubit/post_cubit.dart';
import 'package:flutter_api_tutorial/post_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class AddPostForm extends StatelessWidget {
AddPostForm({super.key});
final titleController = TextEditingController(); // New
final bodyController = TextEditingController(); // New
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Add Post'),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(25.0),
child: TextField(
controller: titleController,
decoration: const InputDecoration(hintText: 'Title'),
),
),
Padding(
padding: const EdgeInsets.all(25.0),
child: TextField(
controller: bodyController,
decoration: const InputDecoration(hintText: 'Body'),
),
),
BlocProvider(
create: (context) => PostCubit(
repository: PostRepositoryImpl(
ApiService(),
),
),
child: ElevatedButton(
onPressed: () {
context.read<PostCubit>().submitPost(
Post(
id: Random().nextInt(150),
title: titleController.text,
body: bodyController.text,
userId: 1,
),
);
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Submitted Successfully'),
),
);
Navigator.pop(context);
},
child: const Text('Submit'),
),
)
],
),
);
}
}
edit_post_form.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_api_tutorial/api_service.dart';
import 'package:flutter_api_tutorial/post.dart';
import 'package:flutter_api_tutorial/post_cubit/post_cubit.dart';
import 'package:flutter_api_tutorial/post_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class EditPostForm extends StatelessWidget {
EditPostForm({required this.titleController, required this.bodyController, super.key});
final titleController;
final bodyController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Edit Post'),
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(25.0),
child: TextField(
controller: titleController,
decoration: const InputDecoration(hintText: 'Title'),
),
),
Padding(
padding: const EdgeInsets.all(25.0),
child: TextField(
controller: bodyController,
decoration: const InputDecoration(hintText: 'Body'),
),
),
BlocProvider(
create: (context) => PostCubit(
repository: PostRepositoryImpl(
ApiService(),
),
),
child: ElevatedButton(
onPressed: () {
context.read<PostCubit>().updatePost(
Post(
id: Random().nextInt(150),
title: titleController.text,
body: bodyController.text,
userId: 1,
),
);
ScaffoldMessenger.of(context)
..removeCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Submitted Successfully'),
),
);
Navigator.pop(context);
},
child: const Text('Submit'),
),
)
],
),
);
}
}
Conclusion
Integrating APIs is an essential skill for building modern, data-driven Flutter apps. By understanding how to make GET, POST, PUT, and DELETE requests, you can seamlessly fetch, create, update, and delete data from remote servers. Combining these techniques with best practices like error handling, JSON parsing, and a scalable service architecture ensures your app is reliable, maintainable, and ready to grow.
The examples and practices in this post should serve as a foundation for integrating APIs in your Flutter projects. As you continue, explore advanced concepts like authentication, pagination, and error logging to take your skills even further. Whether you’re building your next big project or enhancing an existing app, effective API integration is a skill that will elevate your development game.