Intermediate · Lesson 2 · 15 min read
Networking: REST APIs, JSON and Error Handling
Fetch data over HTTP, parse JSON into typed models, handle every failure mode, and show the result without jank.
Updated July 26, 2026
What you will learn
- Make HTTP requests with the http package and with Dio
- Convert JSON into typed Dart models safely
- Handle timeouts, offline states and non-200 responses
- Render async data with FutureBuilder without common pitfalls
Nearly every real app talks to a server. The mechanics are easy; what separates a solid app from a fragile one is how it handles the unhappy paths — slow networks, malformed payloads, expired tokens, and users who leave the screen mid-request.
Setting up
dependencies:
flutter:
sdk: flutter
http: ^1.2.0Android needs the internet permission. iOS allows HTTPS by default and blocks plain HTTP unless you explicitly opt in (don't).
<uses-permission android:name="android.permission.INTERNET" />Typed models
Never pass raw Map<String, dynamic> around your app. Parse at the boundary into a real class, and every layer above gets compiler-checked field names.
class Article {
const Article({
required this.id,
required this.title,
required this.body,
required this.publishedAt,
this.imageUrl,
});
final int id;
final String title;
final String body;
final DateTime publishedAt;
final String? imageUrl;
factory Article.fromJson(Map<String, dynamic> json) {
return Article(
id: json['id'] as int,
title: json['title'] as String? ?? 'Untitled',
body: json['body'] as String? ?? '',
publishedAt:
DateTime.tryParse(json['published_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
imageUrl: json['image_url'] as String?,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'body': body,
'published_at': publishedAt.toIso8601String(),
'image_url': imageUrl,
};
}A repository with real error handling
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
class ApiException implements Exception {
ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
@override
String toString() => 'ApiException($statusCode): $message';
}
class ArticleRepository {
ArticleRepository({http.Client? client, required this.baseUrl})
: _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
Future<List<Article>> fetchArticles({int page = 1}) async {
final uri = Uri.parse('$baseUrl/articles').replace(
queryParameters: {'page': '$page', 'per_page': '20'},
);
try {
final response = await _client
.get(uri, headers: {'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
if (response.statusCode == 200) {
final decoded = jsonDecode(response.body) as List<dynamic>;
return decoded
.map((item) => Article.fromJson(item as Map<String, dynamic>))
.toList();
}
if (response.statusCode == 401) {
throw ApiException('Session expired', statusCode: 401);
}
throw ApiException(
'Request failed',
statusCode: response.statusCode,
);
} on SocketException {
throw ApiException('No internet connection');
} on HttpException {
throw ApiException('Could not reach the server');
} on FormatException {
throw ApiException('Unexpected response from the server');
}
}
void dispose() => _client.close();
}Two details worth copying: the injectable http.Client makes this testable with a mock client and no network, and every low-level exception is translated into one app-level ApiException so the UI has a single thing to catch.
// Top-level function, runs in a separate isolate
List<Article> parseArticles(String body) {
final decoded = jsonDecode(body) as List<dynamic>;
return decoded
.map((item) => Article.fromJson(item as Map<String, dynamic>))
.toList();
}
// In the repository
final articles = await compute(parseArticles, response.body);Displaying async data
FutureBuilder renders a future's states. The classic mistake is creating the future inside build — every rebuild fires a new request.
class ArticleListScreen extends StatefulWidget {
const ArticleListScreen({super.key});
@override
State<ArticleListScreen> createState() => _ArticleListScreenState();
}
class _ArticleListScreenState extends State<ArticleListScreen> {
late Future<List<Article>> _future;
@override
void initState() {
super.initState();
_future = _repo.fetchArticles(); // created ONCE
}
Future<void> _refresh() async {
setState(() => _future = _repo.fetchArticles());
await _future;
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<List<Article>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return ErrorView(
message: switch (snapshot.error) {
ApiException e => e.message,
_ => 'Something went wrong',
},
onRetry: _refresh,
);
}
final articles = snapshot.data ?? const <Article>[];
if (articles.isEmpty) {
return const EmptyView(message: 'No articles yet');
}
return ListView.builder(
itemCount: articles.length,
itemBuilder: (context, i) => ArticleTile(article: articles[i]),
);
},
),
);
}
}That builder handles four states — loading, error, empty and data. Skipping the empty state is the most common omission, and it is the one users notice, because a blank screen is indistinguishable from a broken one.
Dio for larger apps
The http package is deliberately minimal. Once you need interceptors — attaching auth tokens, refreshing them on 401, logging, retries — dio saves real work.
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
final token = tokenStore.accessToken;
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
final refreshed = await tokenStore.refresh();
if (refreshed) {
return handler.resolve(await dio.fetch(error.requestOptions));
}
}
handler.next(error);
},
),
);Cancellation
If a user types in a search box, each keystroke may fire a request. Without cancellation, an early slow response can arrive after a later fast one and overwrite it with stale results.
Timer? _debounce;
int _requestId = 0;
void _onQueryChanged(String query) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () async {
final id = ++_requestId;
final results = await _repo.search(query);
// Ignore responses that are no longer the latest request
if (!mounted || id != _requestId) return;
setState(() => _results = results);
});
}Key takeaways
- Parse JSON into typed models at the boundary; never pass raw maps through your app.
- Translate every network failure into one app-level exception type for the UI to handle.
- Create futures in
initState, not inbuild, or you will re-request on every rebuild. - Always design for four states: loading, error, empty and data.
Practice
Search with debounce
Build a search screen hitting any public JSON API. Debounce input by 300ms, cancel stale responses, show a spinner while loading, a retry button on error, and a friendly empty state. Then write a unit test for the repository using a mocked http.Client.
Show hints
package:http/testing.dartprovidesMockClientfor returning canned responses.- Test the 500 and malformed-JSON paths, not just the happy one.