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.Client so tests can substitute MockClient and 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
ApproachBest forTrade-off
jsonDecode by handOne or two tiny payloadsNo dependency, easy to get wrong
json_serializablePlain data modelsBuild step, but minimal output
freezedModels needing copy and equalityLarger generated code
openapi_generatorA large documented APIRequires 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.

Local persistence: shared_preferences, sqflite, Drift and Hive Platform integration: plugins, permissions and channels

Last refreshed 2026-09-18.