← Back to Articles

Flutter Networking and API Integration: Connecting Your App to the World

Flutter Networking and API Integration: Connecting Your App to the World

Flutter Networking and API Integration: Connecting Your App to the World

When building Flutter apps, you'll almost always need to fetch data from the internet. Whether you're loading user profiles, fetching weather data, or syncing with a backend server, understanding how to make HTTP requests and handle API responses is essential. In this article, we'll explore how to connect your Flutter app to APIs, handle responses gracefully, and follow best practices for network requests.

Getting Started with HTTP Requests

Flutter provides the http package for making network requests. To use it, add the dependency to your pubspec.yaml file:


dependencies:
  flutter:
    sdk: flutter
  http: ^1.1.0

After adding the package, run flutter pub get to install it. Now you're ready to make your first API call!

Making Your First API Request

Let's start with a simple GET request to fetch data from an API. Here's a basic example:


import 'package:http/http.dart' as http;
import 'dart:convert';

Future fetchUserData() async {
  final url = Uri.parse('https://api.example.com/users/1');
  
  try {
    final response = await http.get(url);
    
    if (response.statusCode == 200) {
      final jsonData = jsonDecode(response.body);
      print('User data: $jsonData');
    } else {
      print('Failed to load data: ${response.statusCode}');
    }
  } catch (e) {
    print('Error: $e');
  }
}

This example demonstrates the basic pattern: create a URI, make the request, check the status code, and parse the JSON response. The http.get() method returns a Response object containing the status code, headers, and body.

Here's a visual representation of the HTTP request-response flow:

Flutter App API Server HTTP Request JSON Response

Understanding HTTP Methods

Different HTTP methods serve different purposes. Here's how to use each one:

GET Requests

GET requests are used to retrieve data from a server. They're the most common type of request:


Future> fetchPosts() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/1'),
  );
  
  if (response.statusCode == 200) {
    return jsonDecode(response.body) as Map;
  } else {
    throw Exception('Failed to load post');
  }
}

POST Requests

POST requests are used to send data to a server, typically to create new resources:


Future> 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) {
    return jsonDecode(response.body) as Map;
  } else {
    throw Exception('Failed to create post');
  }
}

PUT and PATCH Requests

PUT requests replace an entire resource, while PATCH requests update specific fields:


Future> updatePost(int id, String title) async {
  final response = await http.patch(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'title': title}),
  );
  
  if (response.statusCode == 200) {
    return jsonDecode(response.body) as Map;
  } else {
    throw Exception('Failed to update post');
  }
}

DELETE Requests

DELETE requests remove resources from the server:


Future deletePost(int id) async {
  final response = await http.delete(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
  );
  
  if (response.statusCode != 200) {
    throw Exception('Failed to delete post');
  }
}

Working with JSON Data

Most APIs return data in JSON format. Flutter's dart:convert library provides tools for encoding and decoding JSON. However, manually parsing JSON can be error-prone. A better approach is to create model classes that represent your data structure.

Let's create a model class for a blog post:


class Post {
  final int id;
  final int userId;
  final String title;
  final String body;

  Post({
    required this.id,
    required this.userId,
    required this.title,
    required this.body,
  });

  factory Post.fromJson(Map json) {
    return Post(
      id: json['id'] as int,
      userId: json['userId'] as int,
      title: json['title'] as String,
      body: json['body'] as String,
    );
  }

  Map toJson() {
    return {
      'id': id,
      'userId': userId,
      'title': title,
      'body': body,
    };
  }
}

Now you can easily convert JSON to Dart objects and vice versa:


Future fetchPost(int id) async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
  );
  
  if (response.statusCode == 200) {
    final json = jsonDecode(response.body) as Map;
    return Post.fromJson(json);
  } else {
    throw Exception('Failed to load post');
  }
}

Future> fetchAllPosts() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/posts'),
  );
  
  if (response.statusCode == 200) {
    final List jsonList = jsonDecode(response.body);
    return jsonList.map((json) => Post.fromJson(json as Map)).toList();
  } else {
    throw Exception('Failed to load posts');
  }
}

Handling Headers and Authentication

Many APIs require authentication tokens or custom headers. You can pass headers to any HTTP request:


Future> fetchProtectedData(String token) async {
  final response = await http.get(
    Uri.parse('https://api.example.com/protected'),
    headers: {
      'Authorization': 'Bearer $token',
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
  );
  
  if (response.statusCode == 200) {
    return jsonDecode(response.body) as Map;
  } else if (response.statusCode == 401) {
    throw Exception('Unauthorized - invalid token');
  } else {
    throw Exception('Failed to load data');
  }
}

For APIs that use API keys, you might include them in headers or as query parameters:


Future> fetchWeatherData(String apiKey, String city) async {
  final url = Uri.parse('https://api.weather.com/v1/current')
      .replace(queryParameters: {
    'key': apiKey,
    'q': city,
  });
  
  final response = await http.get(url);
  
  if (response.statusCode == 200) {
    return jsonDecode(response.body) as Map;
  } else {
    throw Exception('Failed to load weather data');
  }
}

Error Handling Best Practices

Network requests can fail for many reasons: no internet connection, server errors, timeouts, or invalid responses. Proper error handling is crucial for a good user experience.

Here's the error handling flow:

Make Request Success Parse JSON Error Handle Exception Return Data Show Error UI

Here's a robust error handling pattern:


class NetworkException implements Exception {
  final String message;
  final int? statusCode;
  
  NetworkException(this.message, [this.statusCode]);
  
  @override
  String toString() => 'NetworkException: $message (Status: $statusCode)';
}

Future fetchPostWithErrorHandling(int id) async {
  try {
    final response = await http.get(
      Uri.parse('https://jsonplaceholder.typicode.com/posts/$id'),
    ).timeout(
      const Duration(seconds: 10),
      onTimeout: () {
        throw NetworkException('Request timeout');
      },
    );
    
    if (response.statusCode == 200) {
      try {
        final json = jsonDecode(response.body) as Map;
        return Post.fromJson(json);
      } catch (e) {
        throw NetworkException('Invalid JSON response');
      }
    } else if (response.statusCode == 404) {
      throw NetworkException('Post not found', 404);
    } else if (response.statusCode >= 500) {
      throw NetworkException('Server error', response.statusCode);
    } else {
      throw NetworkException('Failed to load post', response.statusCode);
    }
  } on SocketException {
    throw NetworkException('No internet connection');
  } on FormatException {
    throw NetworkException('Invalid response format');
  } catch (e) {
    if (e is NetworkException) {
      rethrow;
    }
    throw NetworkException('Unexpected error: $e');
  }
}

Don't forget to import the necessary libraries:


import 'dart:io';
import 'package:http/http.dart' as http;
import 'dart:convert';

Creating a Reusable API Service

Instead of scattering HTTP calls throughout your app, create a centralized API service class. This makes your code more maintainable and easier to test:

Here's how the API service architecture organizes your code:

UI Widgets ApiService Centralized Logic HTTP Client Calls Uses

class ApiService {
  final String baseUrl;
  final Map defaultHeaders;
  
  ApiService({
    required this.baseUrl,
    Map? headers,
  }) : defaultHeaders = headers ?? {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  };
  
  Future> get(
    String endpoint, {
    Map? headers,
  }) async {
    final url = Uri.parse('$baseUrl$endpoint');
    final response = await http.get(
      url,
      headers: {...defaultHeaders, ...?headers},
    ).timeout(const Duration(seconds: 10));
    
    return _handleResponse(response);
  }
  
  Future> post(
    String endpoint, {
    Map? body,
    Map? headers,
  }) async {
    final url = Uri.parse('$baseUrl$endpoint');
    final response = await http.post(
      url,
      headers: {...defaultHeaders, ...?headers},
      body: body != null ? jsonEncode(body) : null,
    ).timeout(const Duration(seconds: 10));
    
    return _handleResponse(response);
  }
  
  Map _handleResponse(http.Response response) {
    if (response.statusCode >= 200 && response.statusCode < 300) {
      return jsonDecode(response.body) as Map;
    } else {
      throw NetworkException(
        'Request failed with status ${response.statusCode}',
        response.statusCode,
      );
    }
  }
}

Now you can use this service throughout your app:


final apiService = ApiService(
  baseUrl: 'https://api.example.com',
  headers: {
    'Authorization': 'Bearer your_token_here',
  },
);

Future> getPosts() async {
  final response = await apiService.get('/posts');
  final List jsonList = response['data'] as List;
  return jsonList.map((json) => Post.fromJson(json as Map)).toList();
}

Displaying Data in Your UI

When fetching data asynchronously, you'll want to display loading states and handle errors in your UI. The FutureBuilder widget is perfect for this:


class PostsList extends StatelessWidget {
  final Future> futurePosts;
  
  const PostsList({super.key, required this.futurePosts});
  
  @override
  Widget build(BuildContext context) {
    return FutureBuilder>(
      future: futurePosts,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }
        
        if (snapshot.hasError) {
          return Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Icon(Icons.error_outline, size: 48, color: Colors.red),
                const SizedBox(height: 16),
                Text('Error: ${snapshot.error}'),
                ElevatedButton(
                  onPressed: () {
                    // Retry logic
                  },
                  child: const Text('Retry'),
                ),
              ],
            ),
          );
        }
        
        if (!snapshot.hasData || snapshot.data!.isEmpty) {
          return const Center(child: Text('No posts available'));
        }
        
        return ListView.builder(
          itemCount: snapshot.data!.length,
          itemBuilder: (context, index) {
            final post = snapshot.data![index];
            return ListTile(
              title: Text(post.title),
              subtitle: Text(post.body),
            );
          },
        );
      },
    );
  }
}

Working with Pagination

Many APIs return data in pages to limit response size. Here's how to handle paginated responses:


class PaginatedApiService {
  final ApiService apiService;
  
  PaginatedApiService(this.apiService);
  
  Future> fetchPostsPage({
    int page = 1,
    int limit = 10,
  }) async {
    final response = await apiService.get(
      '/posts?page=$page&limit=$limit',
    );
    
    final List jsonList = response['data'] as List;
    return jsonList.map((json) => Post.fromJson(json as Map)).toList();
  }
  
  Future hasMorePages(int currentPage, int limit) async {
    final response = await apiService.get('/posts/count');
    final totalCount = response['total'] as int;
    return (currentPage * limit) < totalCount;
  }
}

Best Practices and Tips

Here are some important tips to keep in mind when working with APIs in Flutter:

  • Always handle errors gracefully: Network requests can fail, so always wrap them in try-catch blocks and provide meaningful error messages to users.
  • Use timeouts: Set reasonable timeouts for your requests to prevent your app from hanging indefinitely.
  • Cache responses when appropriate: For data that doesn't change frequently, consider caching responses to reduce network usage and improve performance.
  • Validate data: Don't trust API responses blindly. Validate the structure and types of data you receive.
  • Use models: Create model classes instead of working with raw JSON maps. This makes your code more maintainable and type-safe.
  • Centralize API logic: Create a service class to handle all API calls. This makes it easier to update endpoints, add authentication, or change error handling logic.
  • Test your network code: Write unit tests for your API service classes, and consider using mock data for development.

Conclusion

Networking is a fundamental part of most Flutter applications. By understanding how to make HTTP requests, handle responses, and manage errors, you can build robust apps that communicate effectively with backend services. Remember to always handle errors gracefully, use proper models for your data, and centralize your API logic for better maintainability. With these practices in place, you'll be well-equipped to integrate any API into your Flutter app.