Dart Tutorial

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 #

PlatformNeeded
AndroidInternet permission in android/app/src/main/AndroidManifest.xml. Debug builds have it already. Release builds need it added
iOSNothing for HTTPS
macOSThe com.apple.security.network.client entitlement
WebThe 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>);
}
MethodPurposeTypical success code
http.getRead200
http.postCreate201
http.put / http.patchReplace or update200
http.deleteRemove200 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 #

NeedOption
Interceptors, automatic retry, upload progress, request cancellingThe dio package
A typed client generated from annotationsretrofit with dio
GraphQLgraphql_flutter or ferry
Live two-way messagesweb_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.

Practise in the playground Updated by Santosh Adhikari