Module 13: HTTP and APIs
Teach: How to make HTTP requests from a Flutter app, parse JSON responses into Dart objects, and display async data with FutureBuilder. See: A complete flow from HTTP GET to parsed model to rendered list, including loading spinners and error states. Feel: That connecting to APIs is a learnable recipe -- request, parse, display -- not black magic.
Your App Needs to Talk to the Internet
Teach: Why networking matters and how to set up the http package for making requests.
See: The http package added to pubspec.yaml and imported with the as http prefix alongside dart:convert.
Feel: That connecting to the internet is just adding a package and writing a few lines of setup.
Every app you use daily -- Instagram, Spotify, Gmail -- is essentially a pretty face on top of data that lives on a server somewhere. Your Flutter app is no different. At some point, it needs to reach out over the internet, ask a server for data, and display what comes back. That conversation happens over HTTP, and the data usually arrives as JSON. In this module, you are going to learn the whole pipeline: make the request, parse the response, and get it on screen.
Adding the http Package
Flutter does not include HTTP networking out of the box. You need the http package:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
Run flutter pub get, then import it:
import 'package:http/http.dart' as http;
import 'dart:convert';
The as http prefix avoids name collisions. dart:convert gives you jsonDecode and jsonEncode for JSON parsing.
Making a GET Request
Teach: How to make an HTTP GET request, check the status code, and parse the JSON response. See: A fetchPosts function that calls JSONPlaceholder and returns decoded data, with each step explained. Feel: That a GET request is a predictable five-step recipe: call, await, check, decode, return.
A GET request is the simplest network call. You are saying to the server: "give me this resource."
Future<List<dynamic>> fetchPosts() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to load posts: ${response.statusCode}');
}
}
Let's break this down:
http.get(Uri.parse(...))sends a GET request to the URL. It returns aFuture<Response>.awaitpauses until the response arrives.response.statusCodetells you if it worked. 200 means success.jsonDecode(response.body)converts the JSON string into Dart objects -- usually aListorMap.- If something went wrong, throw an exception. You will handle it in the UI layer.
We are using JSONPlaceholder throughout this module. It is a free, public API that returns fake data -- posts, users, comments, todos. Perfect for learning. No API keys, no sign-up, no rate limits. Just send a request and get JSON back.
Model Classes: Turning JSON into Dart
Teach: Why raw JSON maps are dangerous and how model classes with fromJson/toJson give you type safety. See: A Post model class that maps cleanly to the JSONPlaceholder API response. Feel: That model classes are a small upfront cost that prevent a huge category of bugs.
That List<dynamic> from jsonDecode is a list of Map<String, dynamic>. You could work with raw maps everywhere:
// This works, but it is fragile
final title = posts[0]['title']; // No autocomplete, no type checking
final oops = posts[0]['titl']; // Typo? No error until runtime.
Model classes fix this by giving JSON data a Dart shape:
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<String, dynamic> json) {
return Post(
id: json['id'],
userId: json['userId'],
title: json['title'],
body: json['body'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'title': title,
'body': body,
};
}
@override
String toString() => 'Post(id: $id, title: $title)';
}
The fromJson factory constructor takes a raw map and produces a typed Post object. Now you get autocomplete, type checking, and compile-time errors for typos.
Parsing a List of JSON Objects
Combine the model class with the fetch function:
Future<List<Post>> fetchPosts() async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((json) => Post.fromJson(json)).toList();
} else {
throw Exception('Failed to load posts');
}
}
Now you get back a List<Post> instead of List<dynamic>. Every post has typed fields. Your IDE can autocomplete post.title instead of you guessing post['title'].
Always create model classes for your API data. The few minutes of setup prevent hours of debugging typos and type errors in raw maps.
Model classes feel like extra work at first. "Why write a whole class when I can just read from a map?" Because that map has no safety net. If the API changes a field name, or you type titl instead of title, you find out at runtime — usually in production. A model class with fromJson catches typos at compile time, gives you autocomplete in your editor, and gives your teammates a single place to see the shape of the data. For bigger apps, packages like json_serializable even generate this code for you. For now, write it by hand — it's a good exercise in seeing the JSON-to-Dart boundary clearly.
Making a POST Request
Teach: How to send data to a server with HTTP POST, including headers and JSON-encoded body. See: A createPost function that sends JSON to the server and parses the created resource back. Feel: That POST follows the same pattern as GET, with just a few extra pieces (headers, body, status 201).
GET retrieves data. POST sends data to the server to create something new.
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) {
return Post.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to create post: ${response.statusCode}');
}
}
Key differences from GET:
- Use
http.postinstead ofhttp.get. - Set the
Content-Typeheader toapplication/jsonso the server knows you are sending JSON. - Encode the data with
jsonEncodeand pass it asbody. - Success is status code 201 (Created), not 200.
An ApiService Class
For a real app, wrap all your HTTP calls in a service class:
class ApiService {
static const _baseUrl = 'https://jsonplaceholder.typicode.com';
Future<List<Post>> fetchPosts() async {
final response = await http.get(Uri.parse('$_baseUrl/posts'));
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((json) => Post.fromJson(json)).toList();
}
throw Exception('Failed to load posts: ${response.statusCode}');
}
Future<Post> fetchPost(int id) async {
final response = await http.get(Uri.parse('$_baseUrl/posts/$id'));
if (response.statusCode == 200) {
return Post.fromJson(jsonDecode(response.body));
}
throw Exception('Failed to load post $id');
}
Future<Post> createPost(String title, String body, int userId) async {
final response = await http.post(
Uri.parse('$_baseUrl/posts'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'title': title,
'body': body,
'userId': userId,
}),
);
if (response.statusCode == 201) {
return Post.fromJson(jsonDecode(response.body));
}
throw Exception('Failed to create post');
}
}
This keeps your UI code clean and your network logic testable.
Two things to notice about the ApiService class. First, the base URL lives in one place — change your API endpoint and you edit one line. Second, none of this code cares about widgets. That matters. When we get to testing in Module 18, you can test the ApiService in isolation with mock HTTP responses, without ever rendering a widget. That separation — network code over here, UI code over there — is what scales to real apps. Beginner code mixes http.get calls into widget build methods. Production code routes everything through a service layer. Start building that habit now.
FutureBuilder: Async Data in the Widget Tree
Teach: How FutureBuilder bridges async data and the synchronous build method with loading, error, and data states. See: A FutureBuilder that shows a spinner while loading, an error screen on failure, and a ListView on success. Feel: That FutureBuilder elegantly solves the async-in-build puzzle with a clear three-state pattern.
Here is the puzzle. You have a function that returns a Future -- the data is not available yet. But build methods must return widgets immediately. You cannot await inside build. FutureBuilder is the bridge. It takes a Future, and while that Future is pending it shows one widget. When the Future completes with data it shows another. If the Future throws an error it shows a third. Three states, one widget, zero hassle.
FutureBuilder<List<Post>>(
future: _postsFuture,
builder: (context, snapshot) {
// State 1: Loading
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(child: CircularProgressIndicator());
}
// State 2: Error
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red),
SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _retry,
child: Text('Retry'),
),
],
),
);
}
// State 3: Data
final posts = snapshot.data!;
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) {
final post = posts[index];
return Card(
child: ListTile(
leading: CircleAvatar(child: Text('${post.id}')),
title: Text(post.title, maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text(post.body, maxLines: 2, overflow: TextOverflow.ellipsis),
),
);
},
);
},
)
The Critical initState Rule
Teach: Why you must store the Future in initState, not create it inside build. See: The wrong way (infinite rebuild loop) vs the right way (stable Future reference). Feel: That this is a real gotcha worth memorizing.
This is wrong:
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Post>>(
future: fetchPosts(), // BAD: creates a new Future every rebuild!
builder: (context, snapshot) { ... },
);
}
Every time build runs, fetchPosts() creates a new Future. FutureBuilder sees a new Future, starts over, shows the loading spinner, which triggers a rebuild, which creates a new Future... infinite loop.
This is right:
class _PostsScreenState extends State<PostsScreen> {
late Future<List<Post>> _postsFuture;
@override
void initState() {
super.initState();
_postsFuture = ApiService().fetchPosts();
}
void _retry() {
setState(() {
_postsFuture = ApiService().fetchPosts();
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Post>>(
future: _postsFuture, // GOOD: same Future reference across rebuilds
builder: (context, snapshot) { ... },
);
}
}
Store the Future in initState. It runs once. To retry, reassign it in setState.
Never call an async function directly in FutureBuilder's future parameter inside build(). Store the Future in a variable initialized in initState().
Loading and Error States Done Right
Teach: How to build production-quality loading and error UI, including the loading button pattern for form submissions. See: Polished loading states with messages, error states with retry buttons, and buttons that show spinners while submitting. Feel: That handling async states well is what separates a demo from a real app.
Real apps need more than a spinner and an error message. Here is a production-quality pattern:
Widget _buildLoadingState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading posts...'),
],
),
);
}
Widget _buildErrorState(Object error) {
return Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.cloud_off, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text(
'Something went wrong',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
error.toString(),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _retry,
icon: Icon(Icons.refresh),
label: Text('Try Again'),
),
],
),
),
);
}
The Loading Button Pattern
For forms that submit data (like a POST request), show a spinner inside the button and disable it:
bool _isLoading = false;
ElevatedButton(
onPressed: _isLoading ? null : _submitForm,
child: _isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text('Submit'),
)
Setting onPressed to null disables the button. The ternary swaps the button text for a spinner.
The difference between a side-project app and a production app often comes down to how they handle these states. Side-project: spinner and nothing else. Production: polished loading screen with a helpful message, a branded empty state, an error screen with a retry button and a clear explanation of what went wrong, and buttons that visibly disable themselves during submission so users don't double-submit. None of this is hard to build — each one is a small widget. What's hard is remembering to build them. Make "what does this look like when loading?" and "what does this look like when it fails?" reflex questions you ask for every async operation.
There Are No Dumb Questions
Teach: Answers to common HTTP and API questions about JSON decoding, offline behavior, async in build, and alternative packages. See: Clear explanations of jsonDecode vs json.decode, offline exceptions, and why dio exists. Feel: That the common confusions around networking have simple, practical answers.
Q: What is the difference between jsonDecode and json.decode?
A: They are the same function. jsonDecode is a top-level function in dart:convert. json.decode is the same function accessed through the json constant. Use whichever you prefer.
Q: What happens if the device is offline?
A: http.get throws a SocketException. Your FutureBuilder's error state will catch it. In production, you might want to catch the specific exception type and show a "check your internet connection" message.
Q: Can I use async/await in a build method?
A: No. build must return a widget synchronously. That is exactly why FutureBuilder exists -- it handles the async gap for you.
Q: Why do I need a User model if the exercise only asks for Posts?
A: Practice. Every API returns different shapes of data, and you need to be comfortable creating model classes for any of them. The User model is also useful if you want to show which user created each post.
Q: Is http the only package for networking in Flutter?
A: No. dio is a popular alternative with features like interceptors, cancel tokens, and automatic JSON serialization. For learning, http is simpler and sufficient. In production apps, many teams prefer dio.
The offline question deserves more attention than most developers give it. Airplanes, subway tunnels, flaky coffee-shop wifi — your users will hit these situations. A good Flutter app catches SocketException specifically and shows a friendly "you appear to be offline" message instead of a cryptic error. Even better, apps that cache data locally can keep showing the last-known content while waiting for the connection to come back. We'll touch on local caching in the next module. For now, just know that your error state should handle connectivity failures distinctly from server errors — they have different causes and different fixes.
Sharpen Your Pencil: Posts App
Teach: How to build a complete app with model classes, an API service, FutureBuilder, and a form that POSTs data. See: A multi-screen posts app with fetch, display, detail view, and create functionality against a live API. Feel: Capable of building any API-backed Flutter app by following the request-parse-display recipe.
Build a complete app that fetches, displays, and creates posts using the JSONPlaceholder API.
Model Classes
models/post.dart
1. Post class with id (int), userId (int), title (String), body (String).
2. Named required-parameter constructor.
3. factory Post.fromJson(Map<String, dynamic> json).
4. Map<String, dynamic> toJson().
5. toString() returning "Post(id: $id, title: $title)".
models/user.dart
1. User class with id (int), name (String), username (String), email (String), phone (String).
2. factory User.fromJson(Map<String, dynamic> json).
3. Map<String, dynamic> toJson().
ApiService (api_service.dart)
- Base URL constant:
'https://jsonplaceholder.typicode.com'. fetchPosts()-- GET/posts, returnList<Post>.fetchPost(int id)-- GET/posts/$id, returnPost.fetchUsers()-- GET/users, returnList<User>.createPost(String title, String body, int userId)-- POST to/posts, returnPost.- All methods throw descriptive exceptions on non-success status codes.
PostsScreen (posts_screen.dart)
StatefulWidget. Store_postsFutureininitState.FutureBuilderwith three states: loading (spinner), error (icon + message + retry button), data (ListView of Cards).- Each card:
CircleAvatarwith post id, title (max 2 lines), body (max 2 lines). - On tap, navigate to
PostDetailScreenpassing thePost. FloatingActionButtonnavigates toCreatePostScreen.
PostDetailScreen (post_detail_screen.dart)
StatelessWidgetreceiving aPostvia constructor.- Displays: post number in
headlineSmall, title in boldtitleLarge, aDivider, body inbodyLarge, and aChipwith user ID.
CreatePostScreen (create_post_screen.dart)
StatefulWidgetwith aFormandGlobalKey<FormState>.TextFormFieldfor title (required, min 5 chars) and body (required, min 10 chars, maxLines 5).- Submit button: validates form, sets
_isLoading = true, callscreatePost, on success shows SnackBar with new post ID and pops, on error shows error SnackBar. - Button shows
CircularProgressIndicatorwhen loading and is disabled.
This is the first time you're building a multi-screen app that talks to a real server. Take it in stages. Build the Post model and the ApiService first, and print results to the console to confirm they work. Then build the list screen. Then the detail. Then the create form. Resist the urge to wire it all up in one go. Each layer — model, service, screen — has its own failure modes, and testing them in isolation makes debugging far easier. When the create form succeeds and you watch the SnackBar pop up with a real post ID from JSONPlaceholder, you'll have completed your first full-stack Flutter interaction.
📝 Module Quiz
Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.
From Server to Screen
Teach: A recap of the complete HTTP pipeline -- request, parse, display -- and how it connects to the rest of the course. See: The full networking story summarized: http package, model classes, FutureBuilder, POST requests. Feel: That talking to APIs is a repeatable recipe you can apply to any server and any data shape.
You now know the full pipeline. Make an HTTP request with the http package. Parse the JSON response into a model class. Display the data using FutureBuilder with proper loading and error states. Send data back with POST requests. This is the pattern behind every app that talks to a server. The API might be different, the model classes will change, but the recipe stays the same: request, parse, display.
Where this fits: HTTP and APIs let your app work with real, dynamic data instead of hardcoded lists. Combined with Provider from the previous module, you can fetch data once and share it across multiple screens. Next, you will learn to store data locally so your app works even when the internet does not.