Networking and serialization
Talking to a REST backend with http or dio, turning JSON into typed models,
and modelling failure so the UI can render it.
Contents
- Choosing a client
- http
- dio
- Interceptors
- Cancellation and timeouts
- Manual JSON parsing
- json_serializable
- freezed
- Modelling failure
- Repositories
- Testing
Choosing a client
| Package | Use when |
|---|---|
http | A handful of endpoints, no interceptors, minimal dependency footprint |
dio | Interceptors, cancellation, upload/download progress, per-request timeouts, structured errors |
flutter pub add http
# or
flutter pub add dio
Both are fine. dio earns its place as soon as you need an auth interceptor or
request cancellation, which is most real apps.
http
import 'dart:convert';
import 'package:http/http.dart' as http;
final response = await http
.get(Uri.parse('https://api.example.com/products'))
.timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
throw HttpException('Unexpected status ${response.statusCode}');
}
final decoded = jsonDecode(response.body) as List<Object?>;
Reuse a http.Client for multiple requests so connections are pooled, and close
it when done:
final client = http.Client();
try {
// ... several requests
} finally {
client.close();
}
dio
final dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
headers: {'Accept': 'application/json'},
),
);
final response = await dio.get<List<dynamic>>('/products',
queryParameters: {'page': 1, 'size': 20});
final products = response.data!
.map((e) => Product.fromJson(e as Map<String, Object?>))
.toList();
Errors arrive as DioException, which carries the type of failure:
try {
await dio.post<void>('/orders', data: order.toJson());
} on DioException catch (e) {
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout => 'The server took too long.',
DioExceptionType.connectionError => 'No connection.',
DioExceptionType.cancel => 'Cancelled.',
DioExceptionType.badResponse =>
'Server returned ${e.response?.statusCode}.',
_ => 'Something went wrong.',
};
throw ApiException(message, statusCode: e.response?.statusCode);
}
Uploads and downloads:
final form = FormData.fromMap({
'file': await MultipartFile.fromFile(path, filename: 'photo.jpg'),
});
await dio.post<void>('/upload', data: form,
onSendProgress: (sent, total) => _progress.value = sent / total);
await dio.download(url, savePath,
onReceiveProgress: (received, total) => ...);
Interceptors
An interceptor is the right place for cross-cutting concerns — authentication, logging, retry — so individual call sites stay clean.
dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await tokenStore.read();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
if (error.response?.statusCode == 401 && !_refreshing) {
_refreshing = true;
try {
await tokenStore.refresh();
final retried = await dio.fetch<dynamic>(error.requestOptions);
return handler.resolve(retried);
} catch (_) {
await session.signOut();
} finally {
_refreshing = false;
}
}
handler.next(error);
},
),
);
Two things this sketch must get right in production: refreshing once for
concurrent 401s (queue the waiters rather than firing N refreshes), and not
retrying the refresh endpoint itself. Add LogInterceptor in debug builds only
— it prints request bodies, which will include credentials.
Cancellation and timeouts
Cancel in-flight requests when the user leaves the screen or types a new search term:
CancelToken? _token;
Future<void> search(String query) async {
_token?.cancel('superseded');
final token = _token = CancelToken();
final response = await dio.get<List<dynamic>>(
'/search',
queryParameters: {'q': query},
cancelToken: token,
);
// ...
}
@override
void dispose() {
_token?.cancel('disposed');
super.dispose();
}
Set timeouts on the client, not just per request; a request with no timeout can hang until the OS gives up, and the user sees a spinner forever.
Manual JSON parsing
For one or two models, hand-written parsing is honest and dependency-free:
class Product {
const Product({required this.id, required this.name, required this.price});
final String id;
final String name;
final double price;
factory Product.fromJson(Map<String, Object?> json) => Product(
id: json['id']! as String,
name: json['name']! as String,
price: (json['price']! as num).toDouble(),
);
Map<String, Object?> toJson() => {'id': id, 'name': name, 'price': price};
}
Note (json['price'] as num).toDouble() — a JSON number that happens to be
whole arrives as int, and casting it straight to double throws. This is the
single most common JSON bug in Dart.
With strict-casts enabled (see Project structure),
the analyser forces you to write these casts explicitly, which is what makes the
error visible at compile time.
json_serializable
Generates the parsing code from annotations.
flutter pub add json_annotation
flutter pub add --dev build_runner json_serializable
import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart';
@JsonSerializable()
class Product {
const Product({required this.id, required this.name, required this.price});
final String id;
final String name;
@JsonKey(name: 'unit_price', defaultValue: 0)
final double price;
factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);
Map<String, Object?> toJson() => _$ProductToJson(this);
}
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch # during development
Commit the generated .g.dart files or generate them in CI — pick one and be
consistent, because a missing generated file is a confusing build failure.
freezed
freezed adds immutability, copyWith, value equality, and sealed unions on
top of the same generation step.
flutter pub add freezed_annotation
flutter pub add --dev build_runner freezed json_serializable
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
@JsonKey(name: 'unit_price') @Default(0) double price,
}) = _Product;
factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);
}
That gives you product.copyWith(name: 'New'), structural == and hashCode,
and a toString — the things you would otherwise hand-write and get subtly
wrong. Union types are where it really pays:
@freezed
sealed class AuthState with _$AuthState {
const factory AuthState.unknown() = AuthUnknown;
const factory AuthState.signedOut() = AuthSignedOut;
const factory AuthState.signedIn(User user) = AuthSignedIn;
}
The generated union is sealed, so a switch over it is checked for
exhaustiveness — see Dart essentials.
Modelling failure
Exceptions are fine for programming errors, but expected failures (offline, unauthorised, validation) read better as values the UI can pattern-match:
sealed class Result<T> {
const Result();
}
final class Ok<T> extends Result<T> {
const Ok(this.value);
final T value;
}
final class Err<T> extends Result<T> {
const Err(this.failure);
final Failure failure;
}
final result = await repository.loadProducts();
return switch (result) {
Ok(value: final products) => ProductList(products: products),
Err(failure: NetworkFailure()) => const OfflineNotice(),
Err(failure: final f) => ErrorView(message: f.message),
};
Whichever you choose, decide once per project. Mixing thrown exceptions and result types in the same layer produces code where nobody knows what can fail.
Repositories
Keep HTTP details out of the UI. The repository owns the client, maps DTOs to domain types, and decides the caching policy.
class ProductRepository {
ProductRepository(this._dio);
final Dio _dio;
Future<Result<List<Product>>> loadProducts({CancelToken? cancelToken}) async {
try {
final response = await _dio.get<List<dynamic>>(
'/products',
cancelToken: cancelToken,
);
final products = response.data!
.map((e) => Product.fromJson(e as Map<String, Object?>))
.toList();
return Ok(products);
} on DioException catch (e) {
return Err(Failure.fromDio(e));
} on FormatException {
return const Err(Failure.malformedResponse());
}
}
}
Caching options, in increasing order of effort: HTTP caching headers honoured by the platform stack, an in-memory map with a TTL, or persisted responses — see Local storage.
Testing
Inject the client so tests can supply a fake. With http, MockClient returns
canned responses:
final client = MockClient((request) async =>
http.Response('[{"id":"1","name":"Espresso","unit_price":9.5}]', 200));
With dio, register a DioAdapter from http_mock_adapter, or inject a fake
Dio. Assert on the parsed domain objects, not on the raw JSON — that keeps the
test honest when the wire format changes. See Testing.
Next: Local storage.