Networking, JSON and serialization
Call an API with http or Dio, add interceptors, generate models with json_serializable and freezed, and model loading, empty and error states honestly.
A small typed client
class ApiException implements Exception {
ApiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => 'ApiException($statusCode): $message';
}
class ApiClient {
ApiClient({http.Client? client, Uri? base})
: _client = client ?? http.Client(),
_base = base ?? Uri.parse('https://api.example.com');
final http.Client _client;
final Uri _base;
Future<List<Article>> articles({int page = 1}) async {
final uri = _base.replace(path: '/articles', queryParameters: {'page': '$page'});
final response = await _client
.get(uri, headers: const {'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
if (response.statusCode != 200) {
throw ApiException(response.statusCode, response.reasonPhrase ?? 'request failed');
}
final body = jsonDecode(response.body) as List<dynamic>;
return body.map((e) => Article.fromJson(e as Map<String, dynamic>)).toList();
}
void close() => _client.close();
}- Inject the
http.Clientso tests can substituteMockClientand never touch the network. - Always set a timeout. A mobile network can hang indefinitely and a spinner with no end is worse than an error.
- Decode into typed models at the boundary; passing raw maps through the widget tree spreads null checks everywhere.
Generated models
import 'package:freezed_annotation/freezed_annotation.dart';
part 'article.freezed.dart';
part 'article.g.dart';
@freezed
class Article with _$Article {
const factory Article({
required int id,
required String title,
@JsonKey(name: 'published_at') required DateTime publishedAt,
String? author,
}) = _Article;
factory Article.fromJson(Map<String, dynamic> json) => _$ArticleFromJson(json);
}
// build_runner regenerates after a model change
// dart run build_runner build --delete-conflicting-outputs| Approach | Best for | Trade-off |
|---|---|---|
jsonDecode by hand | One or two tiny payloads | No dependency, easy to get wrong |
json_serializable | Plain data models | Build step, but minimal output |
freezed | Models needing copy and equality | Larger generated code |
openapi_generator | A large documented API | Requires a spec you trust |
Loading, empty and error states
sealed class AsyncValue<T> {
const AsyncValue();
}
class Loading<T> extends AsyncValue<T> {
const Loading();
}
class Data<T> extends AsyncValue<T> {
const Data(this.value);
final T value;
}
class Failure<T> extends AsyncValue<T> {
const Failure(this.error);
final Object error;
}
Widget buildBody(AsyncValue<List<Article>> state) => switch (state) {
Loading() => const Center(child: CircularProgressIndicator()),
Failure(:final error) => RetryPanel(message: '$error'),
Data(:final value) when value.isEmpty => const EmptyPanel(),
Data(:final value) => ArticleList(articles: value),
};⚠️
A successful HTTP 200 with an empty list is not an error and must not show a retry button. Distinguished empty, error and loading states from the start; collapsing them is the most common cause of confusing mobile UX.
FAQ
http or Dio?
The
http package covers simple calls with no extra weight. Dio earns its place when you need interceptors, request cancellation, upload progress, or a global base configuration and retry policy.How do I handle a token refresh without duplicating code?
Wrap the client in an interceptor or a small decorator that catches a 401, refreshes once, and replays the original request. Queue concurrent 401s so ten parallel calls trigger one refresh, not ten.
Related
Local persistence: shared_preferences, sqflite, Drift and Hive Platform integration: plugins, permissions and channels
Last refreshed 2026-09-18.