Skip to content
FlutterLearn

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

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.0

Android needs the internet permission. iOS allows HTTPS by default and blocks plain HTTP unless you explicitly opt in (don't).

android/app/src/main/AndroidManifest.xml
<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.

lib/models/article.dart
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

lib/data/article_repository.dart
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.

Dart
// 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.

Dart
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.

Dart
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.

Dart
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 in build, 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.dart provides MockClient for returning canned responses.
  • Test the 500 and malformed-JSON paths, not just the happy one.