Flutter Lesson 46 of 83 4 min read
HTTP Requests in Flutter: Fetching Data from an API
Fetch and send data in Flutter with the http package: GET and POST requests, status codes, headers, timeouts and displaying results.
On this page
Flutter apps talk to servers with the http package. The Dart side is covered in detail in the Dart lesson on HTTP requests. This lesson is about using it in an app.
flutter pub add http
Platform setup #
| Platform | Needed |
|---|---|
| Android | Internet permission in android/app/src/main/AndroidManifest.xml. Debug builds have it already. Release builds need it added |
| iOS | Nothing for HTTPS |
| macOS | The com.apple.security.network.client entitlement |
| Web | The server must allow your site with CORS headers |
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
Forgetting this is why an app that works in debug shows no data after release.
Fetch and display #
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
class Post {
const Post({required this.id, required this.title});
final int id;
final String title;
factory Post.fromJson(Map<String, dynamic> json) =>
Post(id: json['id'] as int, title: json['title'] as String);
}
Future<List<Post>> fetchPosts() async {
final response = await http
.get(Uri.parse('https://jsonplaceholder.typicode.com/posts'))
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw Exception('Server returned ${response.statusCode}');
}
final list = jsonDecode(response.body) as List<dynamic>;
return [for (final item in list) Post.fromJson(item as Map<String, dynamic>)];
}
void main() => runApp(const MaterialApp(home: PostsPage()));
class PostsPage extends StatefulWidget {
const PostsPage({super.key});
@override
State<PostsPage> createState() => _PostsPageState();
}
class _PostsPageState extends State<PostsPage> {
late Future<List<Post>> _posts = fetchPosts();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Posts')),
body: FutureBuilder<List<Post>>(
future: _posts,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Could not load posts'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => setState(() => _posts = fetchPosts()),
child: const Text('Try again'),
),
],
),
);
}
final posts = snapshot.data!;
return ListView.separated(
itemCount: posts.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) => ListTile(
leading: CircleAvatar(child: Text('${posts[i].id}')),
title: Text(posts[i].title),
),
);
},
),
);
}
}
The steps are always the same: request, check the status, decode the JSON, convert to objects, display.
Sending data #
Future<Post> createPost(String title, String body) async {
final response = await http.post(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'title': title, 'body': body, 'userId': 1}),
);
if (response.statusCode != 201) {
throw Exception('Could not create the post');
}
return Post.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
| Method | Purpose | Typical success code |
|---|---|---|
http.get | Read | 200 |
http.post | Create | 201 |
http.put / http.patch | Replace or update | 200 |
http.delete | Remove | 200 or 204 |
Authentication headers #
final response = await http.get(
Uri.parse('$baseUrl/me'),
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
);
Store tokens with flutter_secure_storage, not in plain preferences, and never hard-code secrets in the app. Anyone can extract them from the published file.
Put API code in its own class #
Widgets should not know URLs or status codes.
class ApiException implements Exception {
ApiException(this.message);
final String message;
@override
String toString() => message;
}
class PostApi {
PostApi({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
static const _base = 'https://jsonplaceholder.typicode.com';
Future<List<Post>> fetchPosts() async {
try {
final response = await _client
.get(Uri.parse('$_base/posts'))
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final list = jsonDecode(response.body) as List<dynamic>;
return [for (final item in list) Post.fromJson(item as Map<String, dynamic>)];
}
if (response.statusCode == 401) throw ApiException('Please sign in again');
throw ApiException('Server error (${response.statusCode})');
} on http.ClientException {
throw ApiException('Check your internet connection');
} on FormatException {
throw ApiException('The server sent an unexpected response');
}
}
}
The widget now shows error.toString() and gets a message fit for users. Accepting a Client in the constructor lets tests pass a fake that returns canned responses.
Beyond the http package #
| Need | Option |
|---|---|
| Interceptors, automatic retry, upload progress, request cancelling | The dio package |
| A typed client generated from annotations | retrofit with dio |
| GraphQL | graphql_flutter or ferry |
| Live two-way messages | web_socket_channel |
Checklist #
- Always set a timeout.
- Check the status code. A response is not the same as success.
- Convert JSON to model classes at once. Do not pass maps around the app.
- Show loading, error (with retry) and empty states.
- Use HTTPS.
- On a slow connection, the user must still be able to move around the app.
Try it yourself #
Fetch https://jsonplaceholder.typicode.com/users, convert the result into User objects with a name, email and city, and show them in a list. Tapping a user opens a details screen that loads that user’s posts from /posts?userId=ID.